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
seqforge/io/onlist.py ADDED
@@ -0,0 +1,661 @@
1
+ """Barcode-whitelist (onlist) registry, width-generic packing, and the hit-rate test.
2
+
3
+ The registry maps a *name* (``3M-february-2018``, ``737K-august-2016``, …) to a
4
+ :class:`RegistryEntry` (``uri``, ``sha256``, barcode ``width``, ``orientation``). **The real lists
5
+ ship with the package, pre-packed**, so a 10x dataset resolves with no network and no setup; a list we
6
+ do not ship falls back to ``--onlist-dir`` and then to ``pooch``. A *synthetic* list can be registered
7
+ in-memory from the generator's barcode pool, so rung-3 evidence is exercised in tests without the real
8
+ files.
9
+
10
+ Shipping them is affordable because of what a barcode is: 2 bits per base, so 10x's 6 794 880-entry v3
11
+ list is a sorted ``uint32`` array — and *sorted* is what pays, since 6.8 M draws from 4^16 leave ~630
12
+ gaps, the deltas need ~10 bits, and gzip does the rest. **522 kB**, against 12 MB for 10x's own
13
+ ``.txt.gz``. See :func:`encode_codes`.
14
+
15
+ Identity is the barcode **set**, never the file: :func:`codes_sha256`. See it for why the two obvious
16
+ alternatives are both wrong.
17
+
18
+ Barcodes are 2-bit packed into a width-generic integer array (``uint32`` for <=16 bp, ``uint64`` for
19
+ <=32 bp — never a hardcoded 16 bp), which gives O(1) membership for :func:`onlist_hit_rate` and
20
+ ``np.intersect1d`` set-intersection for the confusability check (§2.4). ``onlist_hit_rate`` tests the
21
+ window **forward and reverse-complement** across a **small positional-offset scan** and records the
22
+ winning ``(orientation, offset)`` — a revcomp hit means the barcode read is on the other strand.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import gzip
28
+ import hashlib
29
+ import json
30
+ import os
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+ from typing import Literal
34
+
35
+ import numpy as np
36
+
37
+ Orientation = Literal["forward", "revcomp", "either"]
38
+ Strand = Literal["forward", "revcomp"]
39
+
40
+ _BASE_TO_BITS = {"A": 0, "C": 1, "G": 2, "T": 3}
41
+ _COMPLEMENT = str.maketrans("ACGT", "TGCA")
42
+
43
+ #: ``ord(base) -> 2-bit code`` for uppercase ACGT (matching ``_BASE_TO_BITS``); everything else — N,
44
+ #: lowercase, any non-ACGT, and pad — maps to 255, the "unpackable" sentinel. It is ``>= 4``, so any
45
+ #: window touching one drops out of the packable set exactly as ``pack_barcode`` returns ``None``.
46
+ _ORD_TO_BITS = np.full(256, 255, dtype=np.uint8)
47
+ for _base, _bits in _BASE_TO_BITS.items():
48
+ _ORD_TO_BITS[ord(_base)] = _bits
49
+
50
+
51
+ def revcomp(seq: str) -> str:
52
+ """Reverse-complement an ACGT string (non-ACGT chars pass through unchanged)."""
53
+ return seq.translate(_COMPLEMENT)[::-1]
54
+
55
+
56
+ def pack_barcode(seq: str) -> int | None:
57
+ """2-bit pack an ACGT barcode into an integer, or ``None`` if it contains a non-ACGT base."""
58
+ code = 0
59
+ for ch in seq:
60
+ bits = _BASE_TO_BITS.get(ch)
61
+ if bits is None: # N or any non-ACGT base -> unpackable, never a whitelist hit
62
+ return None
63
+ code = (code << 2) | bits
64
+ return code
65
+
66
+
67
+ def _dtype_for_width(width: int) -> type[np.unsignedinteger]:
68
+ """Pick the narrowest unsigned integer that holds a ``2*width``-bit packed barcode."""
69
+ if width <= 0:
70
+ raise ValueError(f"barcode width must be positive, got {width}")
71
+ if width <= 16:
72
+ return np.uint32
73
+ if width <= 32:
74
+ return np.uint64
75
+ raise ValueError(f"barcode width {width} > 32 bp exceeds the uint64 pack budget")
76
+
77
+
78
+ class PackedOnlist:
79
+ """A width-generic, 2-bit-packed barcode whitelist: O(1) membership + set intersection."""
80
+
81
+ def __init__(self, width: int, codes: np.ndarray) -> None:
82
+ self.width = width
83
+ #: sorted, unique packed codes (dtype uint32 for <=16 bp, uint64 for <=32 bp). This IS the
84
+ #: membership index: it is sorted, so `np.searchsorted` answers containment in O(log n) with
85
+ #: ~27 MB, and there is no reason to also materialize a 6.8M-entry Python `frozenset` (which
86
+ #: cost ~700 MB and was the resolver's whole memory ceiling). Vectorized membership over a
87
+ #: read sample is one `searchsorted` call — see `onlist_hit_rate`.
88
+ self.codes = codes
89
+
90
+ @classmethod
91
+ def from_barcodes(cls, barcodes: list[str]) -> PackedOnlist:
92
+ """Pack a list of equal-width ACGT barcodes into a sorted, de-duplicated array."""
93
+ if not barcodes:
94
+ raise ValueError("cannot build a PackedOnlist from an empty barcode list")
95
+ widths = {len(b) for b in barcodes}
96
+ if len(widths) != 1:
97
+ raise ValueError(f"onlist barcodes are not uniform width: {sorted(widths)}")
98
+ width = widths.pop()
99
+ dtype = _dtype_for_width(width)
100
+ packed: list[int] = []
101
+ for b in barcodes:
102
+ code = pack_barcode(b.upper())
103
+ if code is None:
104
+ raise ValueError(f"onlist barcode {b!r} contains a non-ACGT base")
105
+ packed.append(code)
106
+ arr = np.array(sorted(set(packed)), dtype=dtype)
107
+ return cls(width, arr)
108
+
109
+ @property
110
+ def n_entries(self) -> int:
111
+ """Number of distinct barcodes in the whitelist."""
112
+ return int(self.codes.size)
113
+
114
+ @property
115
+ def floor(self) -> float:
116
+ """Chance hit-rate: probability a uniform-random barcode lands in the whitelist."""
117
+ return self.n_entries / (4.0**self.width)
118
+
119
+ def contains(self, code: int) -> bool:
120
+ """Membership test for an already-packed barcode code, by binary search on the sorted codes."""
121
+ i = int(np.searchsorted(self.codes, code))
122
+ return i < self.codes.size and int(self.codes[i]) == int(code)
123
+
124
+
125
+ def unpack_barcodes(packed: PackedOnlist) -> list[str]:
126
+ """A packed whitelist -> the barcode text ``--soloCBwhitelist`` expects. Inverse of packing.
127
+
128
+ Lives here, beside :func:`pack_barcode`, because it is the same fact read backwards. It used to
129
+ live in ``compose``, which is how ``compose`` came to write a 111 MB file: the code that could
130
+ produce the text was in the module that decided when to.
131
+ """
132
+ bases = "ACGT"
133
+ width = int(packed.width)
134
+ out: list[str] = []
135
+ for code in packed.codes.tolist():
136
+ chars = []
137
+ c = int(code)
138
+ for _ in range(width):
139
+ chars.append(bases[c & 0b11])
140
+ c >>= 2
141
+ out.append("".join(reversed(chars)))
142
+ return sorted(out)
143
+
144
+
145
+ def write_onlist_text(registry: OnlistRegistry, name: str, path: str | Path) -> int:
146
+ """Materialize a whitelist as text at ``path``. Returns the number of barcodes written.
147
+
148
+ **This is a build step, not a compile step**, and that distinction is the whole point of it
149
+ existing. 10x's v3 whitelist is 6 794 880 barcodes = 111 MB of text, and ``compose`` used to
150
+ write it into every run directory: one dataset compiled three ways cost a third of a gigabyte of
151
+ identical bytes, sitting there permanently, for a file STAR reads once. Now a Snakemake rule
152
+ builds it, STAR reads it, and ``temp()`` deletes it — which is exactly what ``temp()`` is for and
153
+ what the shipped ``.smk`` now declares.
154
+
155
+ Writes to a sibling temp file and renames, so a killed run leaves no half-written whitelist for
156
+ the next one to read. A truncated whitelist does not fail loudly: STARsolo exits 0 and emits a
157
+ matrix that merely looks like a thin dataset — the same failure shape as an inverted strand.
158
+ """
159
+ packed = registry.packed(name)
160
+ barcodes = unpack_barcodes(packed)
161
+ target = Path(path)
162
+ target.parent.mkdir(parents=True, exist_ok=True)
163
+ tmp = target.with_suffix(target.suffix + ".partial")
164
+ tmp.write_text("\n".join(barcodes) + "\n")
165
+ tmp.replace(target)
166
+ return len(barcodes)
167
+
168
+
169
+ def intersect_fraction(a: PackedOnlist, b: PackedOnlist) -> float:
170
+ """Fraction of the smaller whitelist that also appears in the larger (onlist_separable, §2.4).
171
+
172
+ Computed by actual set-intersection over the packed arrays (not sha256 inequality — different
173
+ file hashes prove the files differ, not that the barcode *sets* differ). Different-width lists
174
+ cannot collide, so the intersection is 0 by construction.
175
+ """
176
+ if a.width != b.width:
177
+ return 0.0
178
+ inter = np.intersect1d(a.codes, b.codes, assume_unique=True)
179
+ denom = min(a.n_entries, b.n_entries)
180
+ return (int(inter.size) / denom) if denom else 0.0
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class HitResult:
185
+ """The best onlist hit found over the orientation x offset scan (§3.1)."""
186
+
187
+ hit_rate: float
188
+ orientation: Strand
189
+ offset: int
190
+ n_tested: int
191
+ floor: float
192
+
193
+ def score(self, min_rate: float) -> float:
194
+ """Normalized support score in ``[0, 1]``: ``clip((best - floor)/(min - floor), 0, 1)``."""
195
+ span = min_rate - self.floor
196
+ if span <= 0:
197
+ return 1.0 if self.hit_rate >= min_rate else 0.0
198
+ return max(0.0, min(1.0, (self.hit_rate - self.floor) / span))
199
+
200
+
201
+ def _encode_sample(sample: list[str]) -> tuple[np.ndarray, np.ndarray]:
202
+ """Sample reads -> a padded ``(n, maxlen)`` uint8 matrix of 2-bit base codes + a lengths vector.
203
+
204
+ Built ONCE and reused across every ``(strand, offset)`` window — that reuse is the speedup. A
205
+ window scan then becomes a column slice, a vectorized pack, and one ``searchsorted``, in place of
206
+ ``strands * offsets * n`` Python ``pack_barcode`` calls. Non-ACGT bases and pad positions are 255,
207
+ which is ``>= 4``, so any window touching one is unpackable exactly as ``pack_barcode`` is ``None``.
208
+ """
209
+ n = len(sample)
210
+ lengths = np.fromiter((len(s) for s in sample), dtype=np.int64, count=n)
211
+ maxlen = int(lengths.max()) if n else 0
212
+ mat = np.full((n, maxlen), 255, dtype=np.uint8)
213
+ for i, seq in enumerate(sample):
214
+ if seq:
215
+ raw = np.frombuffer(seq.encode("ascii", "replace"), dtype=np.uint8)
216
+ mat[i, : raw.size] = _ORD_TO_BITS[raw]
217
+ return mat, lengths
218
+
219
+
220
+ def _pack_window(mat: np.ndarray, s: int, width: int, *, rc: bool) -> tuple[np.ndarray, np.ndarray]:
221
+ """Pack column window ``[s, s+width)`` into codes + an all-ACGT validity mask, both vectorized.
222
+
223
+ Forward packing matches ``pack_barcode``: the first base is most significant (big-endian base-4).
224
+ Reverse-complement is ``3 - base`` (A<->T, C<->G) on the reversed window — reverse-then-complement
225
+ equals complement-then-reverse since the complement is per-base, and it is computed on the encoded
226
+ matrix, never by re-reading strings. ``valid`` is taken from the forward window (reversal does not
227
+ change which reads are all-ACGT), so an invalid row's garbage code is dropped by the caller.
228
+ """
229
+ win = mat[:, s : s + width].astype(np.uint64)
230
+ valid = (win < 4).all(axis=1)
231
+ if rc:
232
+ win = np.uint64(3) - win[:, ::-1]
233
+ weights = np.uint64(4) ** np.arange(width - 1, -1, -1, dtype=np.uint64)
234
+ packed = (win * weights).sum(axis=1, dtype=np.uint64)
235
+ return packed, valid
236
+
237
+
238
+ def onlist_hit_rate(
239
+ seqs: list[str],
240
+ start: int,
241
+ onlist: PackedOnlist,
242
+ *,
243
+ orientation: Orientation = "either",
244
+ offset_scan: int = 2,
245
+ max_reads: int = 50_000,
246
+ ) -> HitResult:
247
+ """Best whitelist hit-rate for a barcode anchored at ``start`` (width taken from ``onlist``).
248
+
249
+ Tests forward and/or reverse-complement (per ``orientation``) across offsets
250
+ ``[-offset_scan, +offset_scan]`` and returns the winning ``(orientation, offset, hit_rate)``.
251
+ Bounded by ``max_reads``: the sample is already head-limited, and this caps the work again.
252
+
253
+ Vectorized: the sample is encoded once into a base-code matrix, and each window is a slice + a
254
+ ``searchsorted`` against the onlist's sorted codes. ``n_tested`` counts reads long enough to hold
255
+ the window (as before, including non-ACGT reads that cannot hit); ``hit_rate = hits / n_tested``.
256
+ """
257
+ width = onlist.width
258
+ strands: list[Strand]
259
+ if orientation == "forward":
260
+ strands = ["forward"]
261
+ elif orientation == "revcomp":
262
+ strands = ["revcomp"]
263
+ else:
264
+ strands = ["forward", "revcomp"]
265
+
266
+ sample = seqs[:max_reads]
267
+ best = HitResult(hit_rate=0.0, orientation="forward", offset=0, n_tested=0, floor=onlist.floor)
268
+ if not sample:
269
+ return best
270
+
271
+ mat, lengths = _encode_sample(sample)
272
+ maxcol = mat.shape[1]
273
+ wl = onlist.codes # sorted, unique
274
+ for strand in strands:
275
+ for delta in range(-offset_scan, offset_scan + 1):
276
+ s = start + delta
277
+ e = s + width
278
+ # Guard the column range: e > maxcol means no read is long enough (inrange would be empty
279
+ # anyway), but slicing past the matrix would silently return a NARROWER window and pack the
280
+ # wrong width. s < 0 is off the read's 5' end.
281
+ if s < 0 or e > maxcol:
282
+ continue
283
+ inrange = lengths >= e
284
+ tested = int(inrange.sum())
285
+ if tested == 0:
286
+ continue
287
+ packed, valid = _pack_window(mat[inrange], s, width, rc=strand == "revcomp")
288
+ packable = packed[valid]
289
+ if packable.size:
290
+ idx = np.clip(np.searchsorted(wl, packable), 0, wl.size - 1)
291
+ hits = int((wl[idx] == packable).sum())
292
+ else:
293
+ hits = 0
294
+ rate = hits / tested
295
+ if rate > best.hit_rate:
296
+ best = HitResult(
297
+ hit_rate=rate,
298
+ orientation=strand,
299
+ offset=delta,
300
+ n_tested=tested,
301
+ floor=onlist.floor,
302
+ )
303
+ return best
304
+
305
+
306
+ #: Where the shipped, pre-packed whitelists live (package data).
307
+ _ONLIST_DATA = Path(__file__).parent / "onlists"
308
+
309
+ #: Suffix of the shipped form: gzipped little-endian uint32 **deltas** of the sorted code array.
310
+ _PACKED_SUFFIX = ".codes.gz"
311
+
312
+
313
+ def encode_codes(codes: np.ndarray) -> bytes:
314
+ """Serialize a sorted, unique packed-code array to the shipped form.
315
+
316
+ **Delta-then-gzip, and the numbers are why.** The v3 whitelist is 6 794 880 sorted draws from
317
+ 4^16, so consecutive codes differ by ~630 on average: the deltas need ~10 bits where the values
318
+ need 32, and gzip finishes the job. Measured 2026-07-15:
319
+
320
+ raw uint32 27.2 MB .npy 27.2 MB
321
+ .npz 9.7 MB 10x's .txt.gz 12.2 MB
322
+ delta+gzip 0.5 MB <- this
323
+
324
+ 45x smaller than the text it came from, so all three lists ship in well under a megabyte. A `.npy`
325
+ would have been *bigger* than the file we were trying to avoid vendoring.
326
+ """
327
+ deltas = np.diff(codes, prepend=codes.dtype.type(0)).astype(codes.dtype.newbyteorder("<"))
328
+ return gzip.compress(deltas.tobytes(), 6)
329
+
330
+
331
+ def decode_codes(blob: bytes, width: int) -> np.ndarray:
332
+ """Inverse of :func:`encode_codes`: gunzip, then prefix-sum the deltas back to codes."""
333
+ dtype = np.dtype(_dtype_for_width(width)).newbyteorder("<")
334
+ deltas = np.frombuffer(gzip.decompress(blob), dtype=dtype)
335
+ # dtype= pins the accumulator: numpy's default would widen to int64 and hand back a different
336
+ # array than the one that was encoded.
337
+ return np.cumsum(deltas, dtype=dtype)
338
+
339
+
340
+ def codes_sha256(codes: np.ndarray) -> str:
341
+ """The canonical identity of a barcode SET: sha256 over sorted, unique, little-endian codes.
342
+
343
+ This is a better identity than hashing the file, and the difference is not academic:
344
+
345
+ - A `.gz` hash pins the **packaging**. The same 6 794 880 barcodes arrive as a 12 MB `.gz` from
346
+ 10x's Cell Ranger and an 18 MB `.gz` from the scg_lib_structs mirror, agreeing on no bytes.
347
+ - A hash of the decompressed **text** pins the byte order and line endings. Real: `737K-arc-v1`
348
+ has no trailing newline, so a well-meaning re-write that adds one changes the hash while
349
+ changing no barcode.
350
+ - This pins the **barcodes**, and nothing else. Order-independent, duplicate-independent,
351
+ compression-independent, newline-independent. It answers exactly the question we ask of a
352
+ whitelist: *is this the same set?*
353
+ """
354
+ return hashlib.sha256(
355
+ np.ascontiguousarray(codes, dtype=codes.dtype.newbyteorder("<")).tobytes()
356
+ ).hexdigest()
357
+
358
+
359
+ @dataclass(frozen=True)
360
+ class RegistryEntry:
361
+ """A registry record for one onlist.
362
+
363
+ ``sha256`` is the **code-set hash** (:func:`codes_sha256`), not a file hash — see that function
364
+ for why. ``uri`` records where the list came from and is a fallback source; a shipped list needs
365
+ no network at all.
366
+ """
367
+
368
+ name: str
369
+ uri: str
370
+ #: :func:`codes_sha256` of the barcode set. Empty = unverifiable (declared, not pinned).
371
+ sha256: str
372
+ width: int
373
+ orientation: Orientation = "forward"
374
+ n_entries: int | None = None
375
+ #: Where the packed data lives, if it ships with the package. Set by :func:`shipped_entries`.
376
+ packed_path: Path | None = None
377
+ #: Provenance of the text this was packed from — recorded, never checked at run time (the packed
378
+ #: form is sorted and de-duplicated, so the original byte order is gone and this hash cannot be
379
+ #: recomputed from it). It is here so a human can re-derive the data and prove where it came from.
380
+ source_sha256: str = ""
381
+
382
+ @property
383
+ def shipped(self) -> bool:
384
+ return self.packed_path is not None and self.packed_path.is_file()
385
+
386
+ @property
387
+ def fetchable(self) -> bool:
388
+ """Can this be materialized without a local copy? **Derived, never declared.**
389
+
390
+ It was a hand-set field that no code branched on — written in three places, read only by
391
+ `io onlist list` for display, and it said `fetchable=False` on entries whose real problem was
392
+ an empty `uri`. A flag that describes behaviour without causing it is a comment with a bool's
393
+ syntax. Now it cannot disagree with the registry.
394
+ """
395
+ return self.shipped or (bool(self.uri) and not self.uri.startswith("synthetic:"))
396
+
397
+
398
+ class OnlistNotAvailable(RuntimeError):
399
+ """Raised when an onlist cannot be materialized (unknown, or offline + not cached)."""
400
+
401
+
402
+ class OnlistRegistry:
403
+ """Named onlists -> packed whitelists, via pooch (real) or in-memory (synthetic)."""
404
+
405
+ def __init__(
406
+ self,
407
+ *,
408
+ cache_dir: str | Path | None = None,
409
+ offline: bool = False,
410
+ local_dir: str | Path | None = None,
411
+ ) -> None:
412
+ self.offline = offline
413
+ self.cache_dir = str(cache_dir) if cache_dir is not None else None
414
+ #: A directory of already-downloaded lists, looked up as `<local_dir>/<name>.txt[.gz]`.
415
+ #: The escape hatch for a compute node with no internet — which is most of them, and where a
416
+ #: registry that can only fetch is a registry that cannot work. Checked BEFORE the network,
417
+ #: so a present local copy makes `offline` irrelevant rather than fatal. It is still verified
418
+ #: against the content hash, so "local" buys convenience, never trust.
419
+ self.local_dir = str(local_dir) if local_dir is not None else None
420
+ self._entries: dict[str, RegistryEntry] = {}
421
+ self._synthetic: dict[str, list[str]] = {}
422
+ self._packed: dict[str, PackedOnlist] = {}
423
+
424
+ def register(self, entry: RegistryEntry) -> None:
425
+ """Declare a (typically real, pooch-fetchable) onlist without materializing it."""
426
+ self._entries[entry.name] = entry
427
+
428
+ def register_synthetic(
429
+ self, name: str, barcodes: list[str], *, orientation: Orientation = "forward"
430
+ ) -> RegistryEntry:
431
+ """Register an in-memory synthetic onlist from a barcode pool (the pilot fixture)."""
432
+ if not barcodes:
433
+ raise ValueError(f"synthetic onlist {name!r} needs at least one barcode")
434
+ width = len(barcodes[0])
435
+ text = "\n".join(barcodes) + "\n"
436
+ sha = hashlib.sha256(text.encode()).hexdigest()
437
+ entry = RegistryEntry(
438
+ name=name,
439
+ uri=f"synthetic:{name}",
440
+ sha256=sha,
441
+ width=width,
442
+ orientation=orientation,
443
+ n_entries=len(barcodes),
444
+ )
445
+ self._entries[name] = entry
446
+ self._synthetic[name] = list(barcodes)
447
+ self._packed.pop(name, None)
448
+ return entry
449
+
450
+ def has(self, name: str) -> bool:
451
+ return name in self._entries
452
+
453
+ def names(self) -> list[str]:
454
+ return sorted(self._entries)
455
+
456
+ def get(self, name: str) -> RegistryEntry:
457
+ try:
458
+ return self._entries[name]
459
+ except KeyError as exc:
460
+ raise OnlistNotAvailable(f"unknown onlist {name!r}") from exc
461
+
462
+ def packed(self, name: str) -> PackedOnlist:
463
+ """Return the packed whitelist for ``name``, materializing + verifying it if needed."""
464
+ if name in self._packed:
465
+ return self._packed[name]
466
+ entry = self.get(name)
467
+ if name in self._synthetic:
468
+ packed = PackedOnlist.from_barcodes(self._synthetic[name])
469
+ else:
470
+ packed = PackedOnlist(entry.width, self._load_codes(entry))
471
+ self._packed[name] = packed
472
+ return packed
473
+
474
+ def _local_path(self, entry: RegistryEntry) -> Path | None:
475
+ """An already-downloaded copy under ``local_dir``, if there is one."""
476
+ if not self.local_dir:
477
+ return None
478
+ for suffix in (".txt.gz", ".txt", ".gz", ""):
479
+ candidate = Path(self.local_dir) / f"{entry.name}{suffix}"
480
+ if candidate.is_file():
481
+ return candidate
482
+ return None
483
+
484
+ def _load_codes(self, entry: RegistryEntry) -> np.ndarray:
485
+ """Materialize an onlist's packed codes: shipped data, then a local copy, then the network.
486
+
487
+ Shipped first because it is the common case and costs nothing — no network, no unpacking of a
488
+ 27 MB text file, no `--onlist-dir` for the user to know about.
489
+ """
490
+ if entry.shipped:
491
+ assert entry.packed_path is not None
492
+ codes = decode_codes(entry.packed_path.read_bytes(), entry.width)
493
+ return self._verify(entry, codes, entry.packed_path)
494
+
495
+ path = self._local_path(entry)
496
+ if path is None:
497
+ if self.offline:
498
+ raise OnlistNotAvailable(
499
+ f"onlist {entry.name!r} is not shipped or cached, and this registry is offline. "
500
+ f"Point --onlist-dir at a directory containing {entry.name}.txt.gz, or use a "
501
+ f"registry that may fetch."
502
+ )
503
+ if not entry.uri:
504
+ raise OnlistNotAvailable(
505
+ f"onlist {entry.name!r} is not shipped and has no source URL, so it cannot be "
506
+ f"fetched. Download it and point --onlist-dir at the directory holding "
507
+ f"{entry.name}.txt.gz."
508
+ )
509
+ path = Path(self._fetch(entry))
510
+ return self._verify(entry, self._codes_from_text(path), path)
511
+
512
+ def _verify(self, entry: RegistryEntry, codes: np.ndarray, source: Path) -> np.ndarray:
513
+ """Refuse a whitelist that is not the declared barcode set.
514
+
515
+ Refusing rather than warning, because a wrong whitelist does not error downstream: STARsolo
516
+ exits 0 and emits a matrix that merely looks like a thin dataset — the same failure shape as
517
+ an inverted strand (§5), and the reason eager verification exists at all.
518
+ """
519
+ digest = codes_sha256(codes)
520
+ if entry.sha256 and digest != entry.sha256:
521
+ raise OnlistNotAvailable(
522
+ f"onlist {entry.name!r} from {source} is not the same barcodes the registry "
523
+ f"declares: got code-set sha256 {digest} ({codes.size} barcodes), expected "
524
+ f"{entry.sha256}" + (f" ({entry.n_entries} barcodes)" if entry.n_entries else "")
525
+ )
526
+ return codes
527
+
528
+ def _codes_from_text(self, path: Path) -> np.ndarray:
529
+ """Read a whitelist's text (gzipped or not) and pack it.
530
+
531
+ The gzip magic decides, not the file extension: a mirror serving `.txt` that is actually
532
+ gzipped (or the reverse) is a packaging detail, and guessing from the name is how you get a
533
+ whitelist of one line that starts with `\\x1f\\x8b`.
534
+ """
535
+ raw = path.read_bytes()
536
+ if raw[:2] == b"\x1f\x8b":
537
+ raw = gzip.decompress(raw)
538
+ barcodes = [line.strip() for line in raw.decode().splitlines() if line.strip()]
539
+ if not barcodes:
540
+ raise OnlistNotAvailable(f"{path} contains no barcodes")
541
+ return PackedOnlist.from_barcodes(barcodes).codes
542
+
543
+ def _fetch(self, entry: RegistryEntry) -> str:
544
+ """Fetch a real onlist via pooch (the only network touch here).
545
+
546
+ ``known_hash=None`` on purpose, and it is not a hole: `_barcodes_from` verifies the
547
+ **decompressed** bytes against `entry.sha256` immediately after. Pooch can only hash what it
548
+ downloaded, and a download hash pins the *packaging* — the same 6 794 880 barcodes arrive as a
549
+ 12 MB `.gz` from 10x and an 18 MB `.gz` from the scg_lib_structs mirror, agreeing on no bytes
550
+ at all. Pinning that would break on a recompression while proving nothing about the barcodes.
551
+ """
552
+ import pooch # local import: keep the module importable offline / without pooch resolved
553
+
554
+ return str(
555
+ pooch.retrieve(
556
+ url=entry.uri,
557
+ known_hash=None,
558
+ path=self.cache_dir,
559
+ progressbar=False,
560
+ )
561
+ )
562
+
563
+
564
+ def synthetic_registry(pools: dict[str, list[str]]) -> OnlistRegistry:
565
+ """Build a registry whose registry-names are backed by the generator's synthetic pools.
566
+
567
+ ``pools`` maps a *registry name* (e.g. ``3M-february-2018``) to its barcode list. Used by the
568
+ resolve tests so rung-3 onlist evidence fires against the same barcodes the reads were drawn from.
569
+ """
570
+ reg = OnlistRegistry(offline=True)
571
+ for name, barcodes in pools.items():
572
+ reg.register_synthetic(name, barcodes)
573
+ return reg
574
+
575
+
576
+ #: The shipped whitelists' index: `onlists/index.json`, **generated** by `seqforge io onlist pack`
577
+ #: alongside the `.codes.gz` blobs it describes.
578
+ #:
579
+ #: It is a file beside the data it describes, which is the shape this repo distrusts — so it is never
580
+ #: hand-edited, and `test_the_shipped_onlist_index_matches_the_shipped_data` decodes every blob and
581
+ #: checks the index against **what is actually in it**. The index cannot claim a width, a count or a
582
+ #: hash the data disagrees with, and a blob with no index entry (or an entry with no blob) is an error.
583
+ #: Adding a whitelist is: run the verb, commit both files. Nothing to remember.
584
+ _INDEX = _ONLIST_DATA / "index.json"
585
+
586
+
587
+ def shipped_entries() -> list[RegistryEntry]:
588
+ """Every whitelist that ships with the package, discovered from `onlists/index.json`."""
589
+ if not _INDEX.is_file():
590
+ return []
591
+ index = json.loads(_INDEX.read_text())
592
+ out: list[RegistryEntry] = []
593
+ for name, meta in sorted(index.items()):
594
+ out.append(
595
+ RegistryEntry(
596
+ name=name,
597
+ uri=str(meta.get("uri", "")),
598
+ sha256=str(meta["sha256"]),
599
+ width=int(meta["width"]),
600
+ orientation=meta.get("orientation", "forward"),
601
+ n_entries=int(meta["n_entries"]),
602
+ packed_path=_ONLIST_DATA / f"{name}{_PACKED_SUFFIX}",
603
+ source_sha256=str(meta.get("source_sha256", "")),
604
+ )
605
+ )
606
+ return out
607
+
608
+
609
+ def write_shipped(
610
+ name: str,
611
+ codes: np.ndarray,
612
+ *,
613
+ width: int,
614
+ uri: str = "",
615
+ orientation: Orientation = "forward",
616
+ source_sha256: str = "",
617
+ ) -> Path:
618
+ """Pack `codes` into the shipped form and record it in the index. Used by `io onlist pack`.
619
+
620
+ The maintenance half of the "adding a whitelist is dropping a file in" promise: this is the only
621
+ writer of `index.json`, so the index cannot drift from the blobs by hand.
622
+ """
623
+ _ONLIST_DATA.mkdir(parents=True, exist_ok=True)
624
+ blob_path = _ONLIST_DATA / f"{name}{_PACKED_SUFFIX}"
625
+ blob_path.write_bytes(encode_codes(codes))
626
+ index = json.loads(_INDEX.read_text()) if _INDEX.is_file() else {}
627
+ index[name] = {
628
+ "width": int(width),
629
+ "n_entries": int(codes.size),
630
+ "sha256": codes_sha256(codes),
631
+ "orientation": orientation,
632
+ "uri": uri,
633
+ "source_sha256": source_sha256,
634
+ }
635
+ _INDEX.write_text(json.dumps(dict(sorted(index.items())), indent=2, sort_keys=True) + "\n")
636
+ return blob_path
637
+
638
+
639
+ def default_registry(
640
+ *, offline: bool = True, local_dir: str | Path | None = None
641
+ ) -> OnlistRegistry:
642
+ """A registry carrying every shipped whitelist, ready to use with no network and no setup.
643
+
644
+ ``offline`` still defaults True, and now costs almost nothing: the shipped lists need no network,
645
+ so ``offline`` only governs the fallback for a list we do not ship. ``local_dir`` (or
646
+ ``$SEQFORGE_ONLIST_DIR``) points at already-downloaded text copies and is checked before the
647
+ network — the escape hatch for a whitelist that ships with neither us nor a public URL.
648
+ """
649
+ if local_dir is None:
650
+ local_dir = os.environ.get("SEQFORGE_ONLIST_DIR") or None
651
+ reg = OnlistRegistry(offline=offline, local_dir=local_dir)
652
+ for entry in shipped_entries():
653
+ reg.register(entry)
654
+ return reg
655
+
656
+
657
+ #: A shared default registry for the CLI. The shipped whitelists resolve with no network and no
658
+ #: setup; anything else falls back to `--onlist-dir` / `$SEQFORGE_ONLIST_DIR`, and failing that the
659
+ #: resolver ABSTAINs rather than guessing and `compose` exits 3 rather than emitting a
660
+ #: `--soloCBwhitelist` that points at nothing.
661
+ DEFAULT_REGISTRY: OnlistRegistry = default_registry()
Binary file