bioforge 2.3.0__py3-none-win_amd64.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.
@@ -0,0 +1,2092 @@
1
+ """
2
+ biocore.py
3
+ ══════════════════════════════════════════════════════════════════════
4
+ BioForge — high-performance bioinformatics engine built on a unified
5
+ 5-bit biological alphabet.
6
+
7
+ Design principles
8
+ ─────────────────
9
+ • No biological sequence is ever stored as a Python ``str`` in memory.
10
+ • All sequences live as compact, bit-packed NumPy uint8 arrays.
11
+ • All encode/decode operations are fully vectorised — zero Python loops.
12
+ • Sequences are read-only (numpy write-lock) after construction.
13
+
14
+ Unified 5-bit encoding map (32 possible states)
15
+ ─────────────────────────────────────────────────
16
+ State │ Binary │ Symbol
17
+ ───────┼─────────┼──────────────────────────────────────────────────
18
+ 0 │ 00000 │ Adenine ─ nucleotide A
19
+ 1 │ 00001 │ Cytosine ─ nucleotide C
20
+ 2 │ 00010 │ Guanine ─ nucleotide G
21
+ 3 │ 00011 │ Thymine / Uracil ─ nucleotide T / U (shared)
22
+ 4 │ 00100 │ Alanine ─ amino acid A
23
+ 5 │ 00101 │ Cysteine ─ amino acid C
24
+ 6 │ 00110 │ Aspartic acid ─ amino acid D
25
+ 7 │ 00111 │ Glutamic acid ─ amino acid E
26
+ 8 │ 01000 │ Phenylalanine ─ amino acid F
27
+ 9 │ 01001 │ Glycine ─ amino acid G
28
+ 10 │ 01010 │ Histidine ─ amino acid H
29
+ 11 │ 01011 │ Isoleucine ─ amino acid I
30
+ 12 │ 01100 │ Lysine ─ amino acid K
31
+ 13 │ 01101 │ Leucine ─ amino acid L
32
+ 14 │ 01110 │ Methionine ─ amino acid M
33
+ 15 │ 01111 │ Asparagine ─ amino acid N
34
+ 16 │ 10000 │ Proline ─ amino acid P
35
+ 17 │ 10001 │ Glutamine ─ amino acid Q
36
+ 18 │ 10010 │ Arginine ─ amino acid R
37
+ 19 │ 10011 │ Serine ─ amino acid S
38
+ 20 │ 10100 │ Threonine ─ amino acid T
39
+ 21 │ 10101 │ Valine ─ amino acid V
40
+ 22 │ 10110 │ Tryptophan ─ amino acid W
41
+ 23 │ 10111 │ Tyrosine ─ amino acid Y
42
+ 24 │ 11000 │ STOP codon / chain terminator *
43
+ 25 │ 11001 │ Alignment gap ─ -
44
+ 26–30 │ … │ Reserved for future extension
45
+ 31 │ 11111 │ Unknown / ambiguous (N in DNA, X in protein)
46
+ ───────┴─────────┴──────────────────────────────────────────────────
47
+ Memory savings over plain ASCII: 5 bits/symbol → 37.5 % reduction.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import ctypes
53
+ import mmap
54
+ import os
55
+ from dataclasses import dataclass
56
+ from enum import IntEnum
57
+ from typing import Iterator, Optional
58
+
59
+ import numpy as np
60
+
61
+ try:
62
+ from .engine._loader import C_AVAILABLE as _C_AVAILABLE
63
+ from .engine._loader import C_BATCH_AVAILABLE as _C_BATCH_AVAILABLE
64
+ from .engine._loader import C_LIBDEFLATE_AVAILABLE as _C_LIBDEFLATE_AVAILABLE
65
+ from .engine._loader import C_PARALLEL_AVAILABLE as _C_PARALLEL_AVAILABLE
66
+ from .engine._loader import C_PARSER_AVAILABLE as _C_PARSER_AVAILABLE
67
+ from .engine._loader import c_bgzf_decompress_parallel as _c_bgzf_decompress_parallel
68
+ from .engine._loader import c_bgzf_usize as _c_bgzf_usize
69
+ from .engine._loader import c_getitem5 as _c_getitem5
70
+ from .engine._loader import c_gzip_decompress as _c_gzip_decompress
71
+ from .engine._loader import c_is_bgzf as _c_is_bgzf
72
+ from .engine._loader import c_pack5 as _c_pack5
73
+ from .engine._loader import c_parse_mem_parallel as _c_parse_mem_parallel
74
+ from .engine._loader import c_parser_close as _c_parser_close
75
+ from .engine._loader import c_parser_next as _c_parser_next
76
+ from .engine._loader import c_parser_next_batch as _c_parser_next_batch
77
+ from .engine._loader import c_parser_next_fastq as _c_parser_next_fastq
78
+ from .engine._loader import c_parser_open as _c_parser_open
79
+ from .engine._loader import c_unpack5 as _c_unpack5
80
+ except ImportError:
81
+ _C_AVAILABLE = False
82
+ _C_PARSER_AVAILABLE = False
83
+ _C_BATCH_AVAILABLE = False
84
+ _C_PARALLEL_AVAILABLE = False
85
+ _C_LIBDEFLATE_AVAILABLE = False
86
+
87
+
88
+ __all__: list[str] = [
89
+ # Excepciones — importar para capturar errores del motor
90
+ "BioForgeError",
91
+ "SequenceTypeError",
92
+ "SequenceValueError",
93
+ "TranslationError",
94
+ "AlignmentError",
95
+ "BioForgeIOError",
96
+ "EngineError",
97
+ # Núcleo
98
+ "BioCode",
99
+ "SeqType",
100
+ "NUC_LUT",
101
+ "AA_LUT",
102
+ "BitPacker",
103
+ "PackedSequence",
104
+ "FastqRecord",
105
+ "SmartImporter",
106
+ "SequenceStats",
107
+ "compute_stats",
108
+ ]
109
+
110
+
111
+ # ══════════════════════════════════════════════════════════════════════════════
112
+ # §0 EXCEPTIONS — jerarquía de errores de BioForge
113
+ # ══════════════════════════════════════════════════════════════════════════════
114
+
115
+ class BioForgeError(Exception):
116
+ """Base para todos los errores propios de BioForge.
117
+
118
+ Úsala en bloques ``except`` para capturar cualquier error del motor
119
+ sin interferir con el resto de Python::
120
+
121
+ from bioforge import BioForgeError
122
+ try:
123
+ prot = SmartTranslator.translate(seq)
124
+ except BioForgeError as e:
125
+ print(f"Error de BioForge: {e}")
126
+
127
+ Las subclases también heredan de ``TypeError`` o ``ValueError`` según
128
+ corresponda, por lo que el código existente que ya atrapa esos tipos
129
+ estándar sigue funcionando sin cambios.
130
+ """
131
+
132
+
133
+ class SequenceTypeError(BioForgeError, TypeError):
134
+ """Tipo incorrecto al llamar a una función del motor.
135
+
136
+ Se lanza cuando:
137
+
138
+ - Se pasa un ``str``, ``list`` u otro objeto donde se esperaba
139
+ ``PackedSequence``.
140
+ - Se mezclan tipos biológicos incompatibles (NUCLEOTIDE con PROTEIN).
141
+ - El ``seq_type`` de un ``PackedSequence`` no es un valor ``SeqType``.
142
+ """
143
+
144
+
145
+ class SequenceValueError(BioForgeError, ValueError):
146
+ """Valor inválido en una secuencia o en sus metadatos.
147
+
148
+ Se lanza cuando:
149
+
150
+ - ``n_symbols`` es negativo.
151
+ - El buffer ``packed`` es demasiado pequeño para ``n`` símbolos.
152
+ - La secuencia está vacía donde se requiere contenido.
153
+ - ``codes`` no es un array 1-D.
154
+ """
155
+
156
+
157
+ class TranslationError(BioForgeError, ValueError):
158
+ """Error durante la traducción ADN→Proteína.
159
+
160
+ Se lanza cuando:
161
+
162
+ - La secuencia no contiene ningún codón ATG/AUG.
163
+ - El ORF no tiene ningún codón completo tras el ATG.
164
+ - La secuencia es demasiado corta para contener un codón.
165
+ """
166
+
167
+
168
+ class AlignmentError(BioForgeError, ValueError):
169
+ """Error durante el alineamiento o en sus parámetros.
170
+
171
+ Se lanza cuando:
172
+
173
+ - El modo no es ``'global'`` ni ``'semi-global'``.
174
+ - ``width`` es ≤ 0 en ``format_alignment``.
175
+ - Las cadenas alineadas tienen longitudes incongruentes.
176
+ """
177
+
178
+
179
+ class BioForgeIOError(BioForgeError, OSError):
180
+ """No se pudo abrir o leer un archivo de secuencias.
181
+
182
+ Hereda de ``OSError`` (= ``IOError``), por lo que el código que ya atrapa
183
+ errores de E/S sigue funcionando, y además se captura con ``BioForgeError``.
184
+ """
185
+
186
+
187
+ class EngineError(BioForgeError, RuntimeError):
188
+ """Fallo del motor de ingesta: parser, descompresión o conversión BGZF.
189
+
190
+ Se lanza cuando:
191
+
192
+ - El parser por lotes o paralelo devuelve un código de error (buffer
193
+ desbordado, ventana demasiado densa, registro gigante).
194
+ - La (des)compresión BGZF/libdeflate falla.
195
+ - Se pide la vía rápida ``.gz`` sin libdeflate compilado.
196
+
197
+ Hereda de ``RuntimeError`` y se captura con ``BioForgeError``.
198
+ """
199
+
200
+
201
+ # ══════════════════════════════════════════════════════════════════════════════
202
+ # §1 BIOLOGICAL ALPHABET — 5-bit codes (values 0 … 31)
203
+ # ══════════════════════════════════════════════════════════════════════════════
204
+
205
+ class BioCode(IntEnum):
206
+ """
207
+ Unified 5-bit code for every biological symbol supported by the library.
208
+
209
+ Guaranteed range [0, 31]. Stored as ``np.uint8`` in packed arrays,
210
+ where the upper 3 bits are always zero after unpacking.
211
+ """
212
+
213
+ # ── Nucleotides (states 0–3) ──────────────────────────────────────────────
214
+ NUC_A = 0 # Adenine
215
+ NUC_C = 1 # Cytosine
216
+ NUC_G = 2 # Guanine
217
+ NUC_TU = 3 # Thymine (DNA) / Uracil (RNA) — unified slot
218
+
219
+ # ── Amino acids (states 4–23), alphabetical IUPAC 1-letter order ──────────
220
+ AA_A = 4 # Alanine (A)
221
+ AA_C = 5 # Cysteine (C)
222
+ AA_D = 6 # Aspartic acid (D)
223
+ AA_E = 7 # Glutamic acid (E)
224
+ AA_F = 8 # Phenylalanine (F)
225
+ AA_G = 9 # Glycine (G)
226
+ AA_H = 10 # Histidine (H)
227
+ AA_I = 11 # Isoleucine (I)
228
+ AA_K = 12 # Lysine (K)
229
+ AA_L = 13 # Leucine (L)
230
+ AA_M = 14 # Methionine (M)
231
+ AA_N = 15 # Asparagine (N)
232
+ AA_P = 16 # Proline (P)
233
+ AA_Q = 17 # Glutamine (Q)
234
+ AA_R = 18 # Arginine (R)
235
+ AA_S = 19 # Serine (S)
236
+ AA_T = 20 # Threonine (T)
237
+ AA_V = 21 # Valine (V)
238
+ AA_W = 22 # Tryptophan (W)
239
+ AA_Y = 23 # Tyrosine (Y)
240
+
241
+ # ── Special / terminal states (24–31) ────────────────────────────────────
242
+ STOP = 24 # Stop codon / chain terminator *
243
+ GAP = 25 # Alignment gap -
244
+ # 26–30 reserved (e.g. modified bases, selenocysteine, pyrrolysine)
245
+ UNK = 31 # Unknown / ambiguous (N in DNA, X in protein)
246
+
247
+
248
+ class SeqType(IntEnum):
249
+ """Biological sequence alphabet family."""
250
+ NUCLEOTIDE = 0 # DNA or RNA
251
+ PROTEIN = 1 # amino-acid chain
252
+
253
+
254
+ # ══════════════════════════════════════════════════════════════════════════════
255
+ # §2 LOOKUP TABLES — constant-time ASCII ↔ BioCode translation
256
+ # ══════════════════════════════════════════════════════════════════════════════
257
+
258
+ def _build_encode_lut(
259
+ char_map: dict[str, int],
260
+ default: int = BioCode.UNK,
261
+ ) -> np.ndarray:
262
+ """
263
+ Build a 256-element uint8 lookup array indexed by ASCII ordinal.
264
+
265
+ A single ``lut[raw_uint8_array]`` call translates an entire sequence
266
+ in one vectorised numpy step — no Python-level loop required.
267
+
268
+ Parameters
269
+ ----------
270
+ char_map : dict[str, int]
271
+ Character → BioCode mapping; applied to both upper and lower case.
272
+ default : int
273
+ BioCode assigned to any character absent from *char_map*.
274
+
275
+ Returns
276
+ -------
277
+ np.ndarray, dtype=uint8, shape=(256,)
278
+ """
279
+ lut = np.full(256, default, dtype=np.uint8)
280
+ for ch, code in char_map.items():
281
+ lut[ord(ch.upper())] = code
282
+ lut[ord(ch.lower())] = code
283
+ return lut
284
+
285
+
286
+ # ── Nucleotide encode table ────────────────────────────────────────────────────
287
+ NUC_LUT: np.ndarray = _build_encode_lut({
288
+ "A": BioCode.NUC_A,
289
+ "C": BioCode.NUC_C,
290
+ "G": BioCode.NUC_G,
291
+ "T": BioCode.NUC_TU, # DNA thymine
292
+ "U": BioCode.NUC_TU, # RNA uracil → same slot as T
293
+ "N": BioCode.UNK, # IUPAC ambiguous nucleotide
294
+ "-": BioCode.GAP,
295
+ ".": BioCode.GAP,
296
+ })
297
+
298
+ # ── Amino-acid encode table ────────────────────────────────────────────────────
299
+ AA_LUT: np.ndarray = _build_encode_lut({
300
+ "A": BioCode.AA_A, "C": BioCode.AA_C, "D": BioCode.AA_D,
301
+ "E": BioCode.AA_E, "F": BioCode.AA_F, "G": BioCode.AA_G,
302
+ "H": BioCode.AA_H, "I": BioCode.AA_I, "K": BioCode.AA_K,
303
+ "L": BioCode.AA_L, "M": BioCode.AA_M, "N": BioCode.AA_N,
304
+ "P": BioCode.AA_P, "Q": BioCode.AA_Q, "R": BioCode.AA_R,
305
+ "S": BioCode.AA_S, "T": BioCode.AA_T, "V": BioCode.AA_V,
306
+ "W": BioCode.AA_W, "Y": BioCode.AA_Y,
307
+ "*": BioCode.STOP,
308
+ "-": BioCode.GAP,
309
+ "X": BioCode.UNK,
310
+ })
311
+
312
+ # ── Decode maps (BioCode int → canonical single-letter character) ─────────────
313
+ _NUC_DECODE: dict[int, str] = {
314
+ BioCode.NUC_A: "A", BioCode.NUC_C: "C",
315
+ BioCode.NUC_G: "G", BioCode.NUC_TU: "T",
316
+ BioCode.GAP: "-", BioCode.UNK: "N",
317
+ }
318
+ _AA_DECODE: dict[int, str] = {
319
+ BioCode.AA_A: "A", BioCode.AA_C: "C", BioCode.AA_D: "D",
320
+ BioCode.AA_E: "E", BioCode.AA_F: "F", BioCode.AA_G: "G",
321
+ BioCode.AA_H: "H", BioCode.AA_I: "I", BioCode.AA_K: "K",
322
+ BioCode.AA_L: "L", BioCode.AA_M: "M", BioCode.AA_N: "N",
323
+ BioCode.AA_P: "P", BioCode.AA_Q: "Q", BioCode.AA_R: "R",
324
+ BioCode.AA_S: "S", BioCode.AA_T: "T", BioCode.AA_V: "V",
325
+ BioCode.AA_W: "W", BioCode.AA_Y: "Y", BioCode.STOP: "*",
326
+ BioCode.GAP: "-", BioCode.UNK: "X",
327
+ }
328
+
329
+ # ── Protein-exclusive character LUT (for vectorised type auto-detection) ───────
330
+ # Characters that appear in protein FASTA but NOT in the standard IUPAC
331
+ # nucleotide alphabet {A C G T U R Y S W K M B D H V N - .}.
332
+ # Conservative set to minimise false positives on degenerate-nucleotide files.
333
+ _IS_PROTEIN_CHAR: np.ndarray = np.zeros(256, dtype=np.bool_)
334
+ for _ch in "EFILPQefilpq*":
335
+ _IS_PROTEIN_CHAR[ord(_ch)] = True
336
+
337
+ # ── Vectorised decode LUTs (BioCode index → ASCII byte) ────────────────────────
338
+ # Pre-built once at module load. to_string() indexes into these arrays with a
339
+ # single fancy-index op, then calls .tobytes().decode('ascii') — no Python loop.
340
+ _NUC_DECODE_ARR: np.ndarray = np.full(32, ord('?'), dtype=np.uint8)
341
+ for _code, _char in _NUC_DECODE.items():
342
+ _NUC_DECODE_ARR[int(_code)] = ord(_char)
343
+
344
+ _AA_DECODE_ARR: np.ndarray = np.full(32, ord('?'), dtype=np.uint8)
345
+ for _code, _char in _AA_DECODE.items():
346
+ _AA_DECODE_ARR[int(_code)] = ord(_char)
347
+
348
+ # ── Watson-Crick complement LUT (BioCode index → complement BioCode) ───────────
349
+ # A(0)↔T/U(3), C(1)↔G(2). Gaps and UNK map to themselves. All other codes
350
+ # (amino acids, reserved) map to UNK — reverse complement of protein is undefined.
351
+ _NUC_COMPLEMENT: np.ndarray = np.full(32, BioCode.UNK, dtype=np.uint8)
352
+ _NUC_COMPLEMENT[int(BioCode.NUC_A )] = np.uint8(BioCode.NUC_TU)
353
+ _NUC_COMPLEMENT[int(BioCode.NUC_C )] = np.uint8(BioCode.NUC_G)
354
+ _NUC_COMPLEMENT[int(BioCode.NUC_G )] = np.uint8(BioCode.NUC_C)
355
+ _NUC_COMPLEMENT[int(BioCode.NUC_TU)] = np.uint8(BioCode.NUC_A)
356
+ _NUC_COMPLEMENT[int(BioCode.UNK )] = np.uint8(BioCode.UNK)
357
+ _NUC_COMPLEMENT[int(BioCode.GAP )] = np.uint8(BioCode.GAP)
358
+
359
+
360
+ # ══════════════════════════════════════════════════════════════════════════════
361
+ # §3 BIT PACKER — compact 5-bit ↔ uint8 array conversion
362
+ # ══════════════════════════════════════════════════════════════════════════════
363
+
364
+ class BitPacker:
365
+ """
366
+ Stateless utility for lossless 5-bit dense packing.
367
+
368
+ **Bit layout** — MSB-first big-endian bit stream::
369
+
370
+ sym₀[b₄b₃b₂b₁b₀] sym₁[b₄b₃b₂b₁b₀] sym₂ …
371
+ ──────────────────────────────────────────────────
372
+ byte₀[b₇b₆b₅b₄b₃b₂b₁b₀] byte₁ …
373
+
374
+ For 8 symbols (= 40 bits = 5 bytes) the stream is byte-aligned with
375
+ zero padding overhead. Any other length gets ≤ 7 zero-padding bits
376
+ appended in the final byte, transparent to the caller.
377
+
378
+ Both ``pack`` and ``unpack`` are fully vectorised — no Python loops.
379
+ """
380
+
381
+ # Pre-computed constants — allocated once at class definition time.
382
+ _SHIFTS: np.ndarray = np.array([4, 3, 2, 1, 0], dtype=np.uint8) # MSB→LSB
383
+ _WEIGHTS: np.ndarray = np.array([16, 8, 4, 2, 1], dtype=np.uint8) # reconstruction
384
+
385
+ # ── Pack ──────────────────────────────────────────────────────────────────
386
+
387
+ @staticmethod
388
+ def pack(codes: np.ndarray) -> np.ndarray:
389
+ """
390
+ Pack 5-bit BioCode values into a compact uint8 byte array.
391
+
392
+ Parameters
393
+ ----------
394
+ codes : np.ndarray, dtype uint8, values in [0, 31]
395
+ Sequence of BioCode integers to compress.
396
+
397
+ Returns
398
+ -------
399
+ np.ndarray, dtype uint8
400
+ Packed byte array. ``len`` = ⌈``len(codes)`` × 5 / 8⌉.
401
+ Trailing bits in the last byte are zero-padded.
402
+
403
+ Complexity
404
+ ──────────
405
+ Time : O(n) — two vectorised numpy ops.
406
+ Memory: O(n) — peak ≈ 5n bits for the intermediate bit matrix.
407
+ """
408
+ codes = np.asarray(codes, dtype=np.uint8)
409
+ if codes.ndim != 1:
410
+ raise SequenceValueError(
411
+ f"codes debe ser un array 1-D, se recibió shape {codes.shape}. "
412
+ "Pasa un array plano, p.ej. np.array([0, 1, 2], dtype=np.uint8)."
413
+ )
414
+ if _C_AVAILABLE:
415
+ return _c_pack5(codes)
416
+
417
+ # ① Expand each 5-bit code → one row of a (n, 5) bit matrix, MSB first.
418
+ bits: np.ndarray = (
419
+ (codes[:, np.newaxis] >> BitPacker._SHIFTS) & np.uint8(1)
420
+ ) # shape: (n, 5), dtype uint8
421
+
422
+ # ② Flatten to a 1-D bit stream; numpy packs every 8 bits → 1 byte.
423
+ return np.packbits(bits.ravel())
424
+
425
+ # ── Unpack ────────────────────────────────────────────────────────────────
426
+
427
+ @staticmethod
428
+ def unpack(packed: np.ndarray, n: int) -> np.ndarray:
429
+ """
430
+ Unpack a 5-bit packed byte array back to BioCode values.
431
+
432
+ Parameters
433
+ ----------
434
+ packed : np.ndarray, dtype uint8
435
+ Byte array as returned by :meth:`pack`.
436
+ n : int
437
+ Original symbol count (required to trim padding bits).
438
+
439
+ Returns
440
+ -------
441
+ np.ndarray, dtype uint8, shape (n,), values in [0, 31]
442
+ """
443
+ if not isinstance(n, (int, np.integer)) or int(n) < 0:
444
+ raise SequenceValueError(
445
+ f"n debe ser un entero no negativo, se recibió {n!r}."
446
+ )
447
+ n = int(n)
448
+ min_bytes = BitPacker.packed_size(n)
449
+ if len(packed) < min_bytes:
450
+ raise SequenceValueError(
451
+ f"packed tiene {len(packed)} byte(s) pero se necesitan "
452
+ f"al menos {min_bytes} para desempaquetar {n} símbolo(s). "
453
+ "¿El buffer procede de BitPacker.pack() con los mismos datos?"
454
+ )
455
+ if _C_AVAILABLE:
456
+ return _c_unpack5(packed, n)
457
+
458
+ # ① Expand all bytes → individual bits; keep exactly n×5 of them.
459
+ bits: np.ndarray = np.unpackbits(packed)[: n * 5].reshape(n, 5)
460
+
461
+ # ② Dot each row with [16, 8, 4, 2, 1] to restore the 5-bit integer.
462
+ return (bits * BitPacker._WEIGHTS).sum(axis=1, dtype=np.uint8)
463
+
464
+ @staticmethod
465
+ def packed_size(n_symbols: int) -> int:
466
+ """Minimum byte count needed to store ``n_symbols`` 5-bit codes."""
467
+ return (n_symbols * 5 + 7) // 8
468
+
469
+
470
+ # ══════════════════════════════════════════════════════════════════════════════
471
+ # §4 PACKED SEQUENCE — immutable, memory-efficient sequence container
472
+ # ══════════════════════════════════════════════════════════════════════════════
473
+
474
+ @dataclass(eq=False)
475
+ class PackedSequence:
476
+ """
477
+ Immutable container for a single 5-bit packed biological sequence.
478
+
479
+ Biological data lives **only** inside ``data`` — a read-only uint8
480
+ numpy array write-locked after construction. The ``header`` field
481
+ stores FASTA metadata only and is never derived from sequence data.
482
+
483
+ Attributes
484
+ ──────────
485
+ header : FASTA description line (``>`` prefix stripped).
486
+ seq_type : NUCLEOTIDE or PROTEIN.
487
+ n_symbols : Original sequence length in biological symbols.
488
+ data : Read-only uint8 numpy array of 5-bit packed codes.
489
+ ``len(data)`` == ⌈``n_symbols`` × 5 / 8⌉ bytes.
490
+ """
491
+
492
+ header: str
493
+ seq_type: SeqType
494
+ n_symbols: int
495
+ data: np.ndarray # uint8, write-locked, 5-bit packed
496
+
497
+ # ── Construction & validation ──────────────────────────────────────────────
498
+
499
+ def __post_init__(self) -> None:
500
+ """Normalise *data* to a write-locked uint8 array and validate length."""
501
+ if not isinstance(self.n_symbols, (int, np.integer)) or int(self.n_symbols) < 0:
502
+ raise SequenceValueError(
503
+ f"n_symbols debe ser un entero no negativo, se recibió {self.n_symbols!r}."
504
+ )
505
+ if not isinstance(self.seq_type, SeqType):
506
+ raise SequenceTypeError(
507
+ f"seq_type debe ser SeqType.NUCLEOTIDE o SeqType.PROTEIN, "
508
+ f"se recibió {type(self.seq_type).__name__!r}."
509
+ )
510
+ arr = np.asarray(self.data, dtype=np.uint8)
511
+ arr.flags.writeable = False # seal the buffer against mutations
512
+ self.data = arr
513
+
514
+ min_bytes = BitPacker.packed_size(self.n_symbols)
515
+ if len(arr) < min_bytes:
516
+ raise SequenceValueError(
517
+ f"data tiene {len(arr)} byte(s) pero se necesitan "
518
+ f"≥ {min_bytes} para almacenar {self.n_symbols} símbolo(s). "
519
+ "Usa BitPacker.pack(codes) para generar el buffer correcto."
520
+ )
521
+
522
+ # ── Equality & hashing ────────────────────────────────────────────────────
523
+
524
+ def __eq__(self, other: object) -> bool:
525
+ if not isinstance(other, PackedSequence):
526
+ return NotImplemented
527
+ return (
528
+ self.seq_type == other.seq_type
529
+ and self.n_symbols == other.n_symbols
530
+ and self.header == other.header
531
+ and np.array_equal(self.data, other.data)
532
+ )
533
+
534
+ def __hash__(self) -> int:
535
+ # data.tobytes() is O(n) but guarantees content-correct hashing.
536
+ return hash((self.seq_type, self.n_symbols,
537
+ self.header, self.data.tobytes()))
538
+
539
+ # ── Representation ────────────────────────────────────────────────────────
540
+
541
+ def __repr__(self) -> str:
542
+ tag = "NUC" if self.seq_type == SeqType.NUCLEOTIDE else "PRO"
543
+ return (
544
+ f"PackedSequence(type={tag}, n={self.n_symbols:,}, "
545
+ f"packed={self.packed_bytes:,} B, "
546
+ f"saved={100 * (1 - self.memory_ratio):.1f}%, "
547
+ f"header={self.header[:40]!r})"
548
+ )
549
+
550
+ # ── Sequence protocol ─────────────────────────────────────────────────────
551
+
552
+ def __len__(self) -> int:
553
+ """Number of biological symbols in the sequence."""
554
+ return self.n_symbols
555
+
556
+ def __getitem__(self, key: int | slice) -> int | PackedSequence:
557
+ """
558
+ Access one symbol (→ ``int`` BioCode) or a sub-sequence
559
+ (→ new ``PackedSequence``).
560
+
561
+ Single-index access is **O(1)** and reads only the 1–2 bytes
562
+ containing the target 5-bit window without unpacking the whole array.
563
+ Slice access decodes the required range and repacks.
564
+ """
565
+ if isinstance(key, int):
566
+ idx = key + self.n_symbols if key < 0 else key
567
+ if not 0 <= idx < self.n_symbols:
568
+ raise IndexError(
569
+ f"index {key} out of range for sequence of length {self.n_symbols}"
570
+ )
571
+ return self._code_at(idx)
572
+
573
+ if isinstance(key, slice):
574
+ sub_codes = self.decode()[key]
575
+ return PackedSequence(
576
+ header = self.header,
577
+ seq_type = self.seq_type,
578
+ n_symbols = len(sub_codes),
579
+ data = BitPacker.pack(sub_codes),
580
+ )
581
+
582
+ raise TypeError(f"index must be int or slice, not {type(key).__name__}")
583
+
584
+ def _code_at(self, idx: int) -> int:
585
+ """Return the 5-bit BioCode at position *idx* sin desempaquetar todo."""
586
+ if _C_AVAILABLE:
587
+ return _c_getitem5(self.data, idx)
588
+
589
+ bit_pos = idx * 5
590
+ byte_i = bit_pos >> 3
591
+ bit_off = bit_pos & 7
592
+
593
+ buf = np.zeros(2, dtype=np.uint8)
594
+ buf[0] = self.data[byte_i]
595
+ if byte_i + 1 < len(self.data):
596
+ buf[1] = self.data[byte_i + 1]
597
+
598
+ bits = np.unpackbits(buf)[bit_off: bit_off + 5]
599
+ return int((bits * BitPacker._WEIGHTS).sum())
600
+
601
+ # ── Storage properties ────────────────────────────────────────────────────
602
+
603
+ @property
604
+ def packed_bytes(self) -> int:
605
+ """Byte count consumed by the ``data`` array."""
606
+ return int(self.data.nbytes)
607
+
608
+ @property
609
+ def memory_ratio(self) -> float:
610
+ """
611
+ Bytes used per symbol relative to naive 8-bit (ASCII) storage.
612
+
613
+ Ideal value for 5-bit packing: **0.625** (= 5 ÷ 8 = 37.5 % reduction).
614
+ Marginally above 0.625 only for sequences shorter than 8 symbols
615
+ due to byte-alignment padding.
616
+ """
617
+ return self.packed_bytes / self.n_symbols if self.n_symbols else 1.0
618
+
619
+ # ── Decode / output ───────────────────────────────────────────────────────
620
+
621
+ def decode(self) -> np.ndarray:
622
+ """
623
+ Unpack to a uint8 array of :class:`BioCode` values.
624
+
625
+ Returns
626
+ -------
627
+ np.ndarray, dtype uint8, shape (n_symbols,), values in [0, 31]
628
+ """
629
+ return BitPacker.unpack(self.data, self.n_symbols)
630
+
631
+ def to_string(self) -> str:
632
+ """
633
+ Decode to a human-readable single-letter string.
634
+
635
+ **For output / FASTA export only.**
636
+ Do *not* store the result as biological data — storing it defeats
637
+ the library's memory-efficiency design.
638
+
639
+ Returns
640
+ -------
641
+ str — canonical IUPAC single-letter sequence.
642
+ """
643
+ lut = (
644
+ _NUC_DECODE_ARR
645
+ if self.seq_type == SeqType.NUCLEOTIDE
646
+ else _AA_DECODE_ARR
647
+ )
648
+ return lut[self.decode()].tobytes().decode("ascii")
649
+
650
+ def reverse_complement(self) -> PackedSequence:
651
+ """
652
+ Compute the reverse complement (5'→3' antisense strand).
653
+
654
+ Watson-Crick pairing: A↔T/U, C↔G. Unknown bases (N) and gaps (-)
655
+ map to themselves. Two fully vectorised NumPy operations: flip + LUT.
656
+
657
+ Returns
658
+ -------
659
+ PackedSequence (NUCLEOTIDE) with header prefixed ``[RC]``.
660
+
661
+ Raises
662
+ ------
663
+ SequenceTypeError
664
+ If the sequence is not ``SeqType.NUCLEOTIDE``.
665
+ """
666
+ if self.seq_type != SeqType.NUCLEOTIDE:
667
+ raise SequenceTypeError(
668
+ f"reverse_complement() requiere SeqType.NUCLEOTIDE, "
669
+ f"se recibió {self.seq_type.name}. "
670
+ "Las proteínas no tienen complemento de Watson-Crick."
671
+ )
672
+ rc = _NUC_COMPLEMENT[self.decode()[::-1]] # flip + complement, two vectorised ops
673
+ return PackedSequence(
674
+ header = f"[RC] {self.header}",
675
+ seq_type = SeqType.NUCLEOTIDE,
676
+ n_symbols = self.n_symbols,
677
+ data = BitPacker.pack(rc),
678
+ )
679
+
680
+
681
+ # ══════════════════════════════════════════════════════════════════════════════
682
+ # §5 FASTQ RECORD — secuencia 5-bit + calidades Phred
683
+ # ══════════════════════════════════════════════════════════════════════════════
684
+
685
+ @dataclass
686
+ class FastqRecord:
687
+ """
688
+ Un registro FASTQ: secuencia nucleotídica empaquetada + calidades Phred.
689
+
690
+ Attributes
691
+ ──────────
692
+ sequence : PackedSequence (SeqType.NUCLEOTIDE, 5-bit packed)
693
+ quality : np.ndarray uint8, valores Phred 0–93 (ASCII-33 ya restado).
694
+ Longitud idéntica a ``sequence.n_symbols``.
695
+
696
+ Quick start
697
+ ───────────
698
+ >>> for rec in SmartImporter.stream_fastq("reads.fastq"):
699
+ ... if rec.passes_quality(20):
700
+ ... process(rec.sequence)
701
+ """
702
+
703
+ sequence: PackedSequence
704
+ quality: np.ndarray # uint8, Phred 0–93
705
+
706
+ @property
707
+ def mean_quality(self) -> float:
708
+ """Calidad Phred media de la lectura."""
709
+ return float(self.quality.mean()) if len(self.quality) > 0 else 0.0
710
+
711
+ def passes_quality(self, min_q: int) -> bool:
712
+ """True si la calidad Phred media es ≥ min_q."""
713
+ return self.mean_quality >= min_q
714
+
715
+ def __repr__(self) -> str:
716
+ return (
717
+ f"FastqRecord(n={self.sequence.n_symbols:,}, "
718
+ f"q_mean={self.mean_quality:.1f}, "
719
+ f"header={self.sequence.header[:40]!r})"
720
+ )
721
+
722
+
723
+ # ══════════════════════════════════════════════════════════════════════════════
724
+ # §5b COLUMNAR BATCHES — miles de registros como matrices, sin objeto/registro
725
+ # ══════════════════════════════════════════════════════════════════════════════
726
+ #
727
+ # La ruta rápida de v2.0. En vez de fabricar un objeto Python por registro
728
+ # (≈5 µs cada uno = ~2 s para 200 000 lecturas), se conservan las matrices
729
+ # contiguas que el motor C ya produce y los análisis se hacen como operaciones
730
+ # NumPy sobre columnas enteras. Los objetos individuales (PackedSequence /
731
+ # FastqRecord) se materializan **solo** cuando se piden con indexación.
732
+ #
733
+ # Caso ideal (Illumina): todas las lecturas miden lo mismo → las calidades son
734
+ # una matriz 2-D limpia (m × L) y filtrar es indexación booleana pura.
735
+
736
+
737
+ def _gather_headers(
738
+ hdr_raw: bytes, hdr_off: np.ndarray, idx: np.ndarray
739
+ ) -> "tuple[bytes, np.ndarray]":
740
+ """Reconstruye el blob de cabeceras para los registros seleccionados."""
741
+ parts: list[bytes] = []
742
+ new_off = np.empty(len(idx) + 1, dtype=np.int32)
743
+ new_off[0] = 0
744
+ cur = 0
745
+ for k, j in enumerate(idx):
746
+ seg = hdr_raw[int(hdr_off[j]): int(hdr_off[j + 1])] # incluye '\0'
747
+ parts.append(seg)
748
+ cur += len(seg)
749
+ new_off[k + 1] = cur
750
+ return b"".join(parts), new_off
751
+
752
+
753
+ # ── Cortes de ventana para el parser paralelo ────────────────────────────────
754
+ # Devuelven el offset del INICIO del último registro del bloque; los registros
755
+ # anteriores están completos y se parsean, el resto se arrastra a la siguiente
756
+ # ventana. Asumen que el bloque empieza en un límite de registro.
757
+
758
+ def _cut_fasta(data: bytes) -> int:
759
+ i = data.rfind(b"\n>")
760
+ return i + 1 if i != -1 else 0
761
+
762
+
763
+ def _cut_fastq(data: bytes) -> int:
764
+ pos = len(data)
765
+ while True:
766
+ i = data.rfind(b"\n@", 0, pos)
767
+ if i == -1:
768
+ return 0
769
+ rec = i + 1
770
+ nl1 = data.find(b"\n", rec)
771
+ if nl1 == -1:
772
+ pos = i; continue
773
+ nl2 = data.find(b"\n", nl1 + 1)
774
+ if nl2 == -1:
775
+ pos = i; continue
776
+ if data[nl2 + 1: nl2 + 2] == b"+": # verificado: registro real
777
+ return rec
778
+ pos = i
779
+
780
+
781
+ def _columnar_batch(m, pack_buf, pack_off, n_syms, types,
782
+ hdr_buf, hdr_off, qual_buf, qual_off, fastq):
783
+ """Construye un SequenceBatch/ReadBatch desde los buffers de salida de C.
784
+
785
+ Compartido por el camino columnar secuencial y el paralelo. Una copia por
786
+ lote desacopla de los buffers reutilizados; string_at copia solo lo usado.
787
+ """
788
+ pack_used = int(pack_off[m])
789
+ hdr_used = int(hdr_off[m])
790
+ packed = pack_buf[:pack_used].copy()
791
+ poff = pack_off[: m + 1].copy()
792
+ nsy = n_syms[:m].copy()
793
+ tps = types[:m].copy()
794
+ hraw = ctypes.string_at(ctypes.addressof(hdr_buf), hdr_used)
795
+ hoff = hdr_off[: m + 1].copy()
796
+ if not fastq:
797
+ return SequenceBatch(packed, poff, nsy, tps, hraw, hoff)
798
+ qual_used = int(qual_off[m])
799
+ fixed = (int(nsy[0]) if (m > 0 and nsy[0] > 0
800
+ and bool(np.all(nsy == nsy[0]))) else 0)
801
+ if fixed and qual_used == m * fixed:
802
+ qual = qual_buf[:qual_used].copy().reshape(m, fixed)
803
+ qoff = None
804
+ else:
805
+ fixed = 0
806
+ qual = qual_buf[:qual_used].copy()
807
+ qoff = qual_off[: m + 1].copy()
808
+ return ReadBatch(packed, poff, nsy, tps, hraw, hoff, qual, qoff, fixed)
809
+
810
+
811
+ # ── Núcleo vectorizado de GC y k-meros (compartido por ambos lotes) ──────────
812
+
813
+ _GC_W = np.array([16, 8, 4, 2, 1], dtype=np.uint8) # pesos de bit MSB→LSB
814
+
815
+
816
+ def _decode_fixed_2d(packed: np.ndarray, m: int, L: int) -> np.ndarray:
817
+ """Decodifica m registros de longitud fija L → matriz (m, L) de BioCode.
818
+
819
+ Totalmente vectorizado: una sola ``unpackbits`` sobre toda la matriz de
820
+ bytes empaquetados. Aprovecha que cada registro ocupa exactamente
821
+ ``plen`` bytes cuando todas las longitudes coinciden.
822
+ """
823
+ plen = (L * 5 + 7) // 8
824
+ packed2d = packed[: m * plen].reshape(m, plen)
825
+ bits = np.unpackbits(packed2d, axis=1)[:, : L * 5].reshape(m, L, 5)
826
+ return (bits * _GC_W).sum(axis=2, dtype=np.uint8)
827
+
828
+
829
+ def _decode_cached(obj) -> "Optional[np.ndarray]":
830
+ """Decodifica el lote a una matriz (m, L) UNA vez y la cachea en ``obj``.
831
+
832
+ Devuelve ``None`` si las longitudes no son fijas (ruta irregular). Compartido
833
+ por ``gc_content()`` y ``kmer_spectrum()`` para no desempaquetar dos veces.
834
+ """
835
+ if getattr(obj, "_dec_done", False):
836
+ return obj._dec_cache
837
+ n = obj._n_syms
838
+ out = (_decode_fixed_2d(obj._packed, len(obj), int(n[0]))
839
+ if (len(obj) and int(n[0]) > 0 and bool(np.all(n == n[0]))) else None)
840
+ obj._dec_cache = out
841
+ obj._dec_done = True
842
+ return out
843
+
844
+
845
+ def _batch_gc(packed, pack_off, n_syms, codes2d) -> np.ndarray:
846
+ """Fracción GC (0..1) de cada registro. ``codes2d`` = matriz cacheada o None."""
847
+ m = int(n_syms.shape[0])
848
+ if m == 0:
849
+ return np.empty(0, dtype=np.float64)
850
+ if codes2d is not None:
851
+ gc = ((codes2d == 1) | (codes2d == 2)).sum(axis=1)
852
+ return gc / codes2d.shape[1]
853
+ # Longitud irregular: bucle por registro (cada uno se decodifica vectorizado).
854
+ out = np.empty(m, dtype=np.float64)
855
+ for i in range(m):
856
+ n = int(n_syms[i])
857
+ if n == 0:
858
+ out[i] = 0.0
859
+ continue
860
+ c = BitPacker.unpack(packed[int(pack_off[i]): int(pack_off[i + 1])], n)
861
+ out[i] = float(((c == 1) | (c == 2)).sum()) / n
862
+ return out
863
+
864
+
865
+ def _batch_kmer_spectrum(packed, pack_off, n_syms, k: int, codes2d) -> np.ndarray:
866
+ """Espectro de k-meros del lote → array int64 de longitud 4**k.
867
+
868
+ Cuenta todos los k-meros de todas las secuencias; los que tienen bases
869
+ ambiguas (código > 3) se descartan. ``codes2d`` = matriz cacheada o None.
870
+ """
871
+ if k < 1:
872
+ raise SequenceValueError(f"k debe ser >= 1, se recibió {k}.")
873
+ n_kmers = 4 ** k
874
+ out = np.zeros(n_kmers, dtype=np.int64)
875
+ m = int(n_syms.shape[0])
876
+ if m == 0:
877
+ return out
878
+ powers = (4 ** np.arange(k - 1, -1, -1)).astype(np.int64)
879
+ sw = np.lib.stride_tricks.sliding_window_view
880
+
881
+ if codes2d is not None:
882
+ if codes2d.shape[1] < k:
883
+ return out # ninguna ventana válida
884
+ win = sw(codes2d, k, axis=1) # (m, L-k+1, k)
885
+ valid = (win <= 3).all(axis=2) # (m, L-k+1)
886
+ ids = (win.astype(np.int64) * powers).sum(axis=2) # (m, L-k+1)
887
+ return np.bincount(ids[valid].ravel(), minlength=n_kmers)[:n_kmers]
888
+
889
+ for i in range(m):
890
+ n = int(n_syms[i])
891
+ if n < k:
892
+ continue
893
+ c = BitPacker.unpack(
894
+ packed[int(pack_off[i]): int(pack_off[i + 1])], n).astype(np.int64)
895
+ win = sw(c, k) # (n-k+1, k)
896
+ valid = (win <= 3).all(axis=1)
897
+ ids = (win * powers).sum(axis=1)[valid]
898
+ out += np.bincount(ids, minlength=n_kmers)[:n_kmers]
899
+ return out
900
+
901
+
902
+ @dataclass
903
+ class SequenceBatch:
904
+ """
905
+ Lote columnar de secuencias FASTA.
906
+
907
+ Guarda todas las secuencias de un lote como matrices contiguas. El acceso
908
+ a un registro concreto (``batch[i]``) materializa un :class:`PackedSequence`
909
+ en ese momento; las operaciones de conjunto se hacen vectorizadas.
910
+
911
+ No instancies esto a mano — lo produce :meth:`SmartImporter.stream_batches`.
912
+ """
913
+
914
+ _packed: np.ndarray # uint8, secuencias 5-bit concatenadas (byte-alineadas)
915
+ _pack_off: np.ndarray # int32, offsets de byte, len = m+1
916
+ _n_syms: np.ndarray # int32, longitud de cada registro, len = m
917
+ _types: np.ndarray # int32, tipo (0=nuc,1=prot), len = m
918
+ _hdr_raw: bytes # blob de cabeceras null-terminadas
919
+ _hdr_off: np.ndarray # int32, offsets de cabecera, len = m+1
920
+
921
+ def __len__(self) -> int:
922
+ return int(self._n_syms.shape[0])
923
+
924
+ @property
925
+ def n_symbols(self) -> np.ndarray:
926
+ """Longitud de cada registro del lote (array int32)."""
927
+ return self._n_syms
928
+
929
+ def header(self, i: int) -> str:
930
+ """Cabecera del registro ``i`` (decodificada bajo demanda)."""
931
+ i = int(i)
932
+ return self._hdr_raw[
933
+ int(self._hdr_off[i]): int(self._hdr_off[i + 1]) - 1
934
+ ].decode("ascii", errors="replace")
935
+
936
+ def __getitem__(self, i: int) -> PackedSequence:
937
+ n = len(self)
938
+ i = int(i)
939
+ if i < 0:
940
+ i += n
941
+ if not 0 <= i < n:
942
+ raise IndexError(f"índice {i} fuera de rango (lote de {n})")
943
+ return PackedSequence(
944
+ header = self.header(i),
945
+ seq_type = SeqType(int(self._types[i])),
946
+ n_symbols = int(self._n_syms[i]),
947
+ data = self._packed[
948
+ int(self._pack_off[i]): int(self._pack_off[i + 1])
949
+ ].copy(),
950
+ )
951
+
952
+ def __iter__(self) -> "Iterator[PackedSequence]":
953
+ for i in range(len(self)):
954
+ yield self[i]
955
+
956
+ # ── Análisis vectorizado de composición (solo nucleótidos) ──────────────
957
+ def _require_nucleotide(self, op: str) -> None:
958
+ if len(self) and bool((self._types == 1).any()):
959
+ raise SequenceTypeError(
960
+ f"{op} solo aplica a secuencias nucleotídicas; el lote contiene "
961
+ "proteínas. Filtra por tipo antes de llamarlo."
962
+ )
963
+
964
+ def gc_content(self) -> np.ndarray:
965
+ """Fracción GC (0..1) de cada secuencia del lote (vectorizado)."""
966
+ self._require_nucleotide("gc_content()")
967
+ return _batch_gc(self._packed, self._pack_off, self._n_syms,
968
+ _decode_cached(self))
969
+
970
+ def kmer_spectrum(self, k: int) -> np.ndarray:
971
+ """Espectro de k-meros del lote → array int64 de longitud ``4**k``."""
972
+ self._require_nucleotide("kmer_spectrum()")
973
+ return _batch_kmer_spectrum(self._packed, self._pack_off, self._n_syms,
974
+ k, _decode_cached(self))
975
+
976
+ def __repr__(self) -> str:
977
+ return (f"SequenceBatch(m={len(self)}, "
978
+ f"bases={int(self._n_syms.sum()):,})")
979
+
980
+
981
+ @dataclass
982
+ class ReadBatch:
983
+ """
984
+ Lote columnar de lecturas FASTQ — la vía rápida para control de calidad.
985
+
986
+ Las calidades de todo el lote viven en una sola matriz. Filtrar por calidad
987
+ media es una operación NumPy sobre las ``m`` lecturas a la vez, sin fabricar
988
+ un objeto por lectura. ``batch[i]`` materializa un :class:`FastqRecord`
989
+ solo cuando lo pides.
990
+
991
+ Caso de longitud fija (Illumina): ``_fixed_len > 0`` y las calidades son una
992
+ matriz 2-D ``(m, L)``. Caso irregular (Nanopore): calidades concatenadas en
993
+ 1-D con ``_qual_off``.
994
+
995
+ No instancies esto a mano — lo produce :meth:`SmartImporter.stream_fastq_batches`.
996
+ """
997
+
998
+ _packed: np.ndarray
999
+ _pack_off: np.ndarray
1000
+ _n_syms: np.ndarray
1001
+ _types: np.ndarray
1002
+ _hdr_raw: bytes
1003
+ _hdr_off: np.ndarray
1004
+ _qual: np.ndarray # 2-D (m,L) si fijo; 1-D concatenado si no
1005
+ _qual_off: "Optional[np.ndarray]" # None si longitud fija
1006
+ _fixed_len: int # >0 = longitud fija; 0 = irregular
1007
+
1008
+ def __len__(self) -> int:
1009
+ return int(self._n_syms.shape[0])
1010
+
1011
+ @property
1012
+ def n_symbols(self) -> np.ndarray:
1013
+ return self._n_syms
1014
+
1015
+ # ── Operaciones vectorizadas sobre TODO el lote ─────────────────────────
1016
+ def mean_quality(self) -> np.ndarray:
1017
+ """Calidad Phred media de cada lectura (array float, una op NumPy)."""
1018
+ if len(self) == 0:
1019
+ return np.empty(0, dtype=np.float64)
1020
+ if self._fixed_len:
1021
+ return self._qual.mean(axis=1)
1022
+ # Irregular: suma por segmentos con reduceat (vectorizado).
1023
+ starts = self._qual_off[:-1]
1024
+ sums = np.add.reduceat(self._qual.astype(np.int64), starts)
1025
+ n = self._n_syms.astype(np.int64)
1026
+ return np.where(n > 0, sums / np.maximum(n, 1), 0.0)
1027
+
1028
+ def passes(self, min_q: float) -> np.ndarray:
1029
+ """Máscara booleana: True donde la calidad media ≥ ``min_q``."""
1030
+ return self.mean_quality() >= min_q
1031
+
1032
+ def decoded_2d(self) -> "Optional[np.ndarray]":
1033
+ """Códigos BioCode como matriz ``(m, L)`` si todas las lecturas miden
1034
+ igual; ``None`` si la longitud es irregular. Decodifica una sola vez
1035
+ (cacheado), compartido con ``gc_content``/``kmer_spectrum``."""
1036
+ return _decode_cached(self)
1037
+
1038
+ def quality_matrix(self) -> "Optional[np.ndarray]":
1039
+ """Calidades Phred como matriz ``(m, L)`` si la longitud es fija;
1040
+ ``None`` si es irregular (usa ``quality_of(i)`` por lectura)."""
1041
+ return self._qual if self._fixed_len else None
1042
+
1043
+ def gc_content(self) -> np.ndarray:
1044
+ """Fracción GC (0..1) de cada lectura del lote (vectorizado)."""
1045
+ return _batch_gc(self._packed, self._pack_off, self._n_syms,
1046
+ _decode_cached(self))
1047
+
1048
+ def kmer_spectrum(self, k: int) -> np.ndarray:
1049
+ """Espectro de k-meros del lote → array int64 de longitud ``4**k``.
1050
+
1051
+ Cuenta todos los k-meros de todas las lecturas (los que tienen bases
1052
+ ambiguas se descartan). Útil para perfiles de k-meros, corrección de
1053
+ errores o estimación de cobertura — sin crear objetos por lectura.
1054
+ """
1055
+ return _batch_kmer_spectrum(self._packed, self._pack_off,
1056
+ self._n_syms, k, _decode_cached(self))
1057
+
1058
+ def filter(self, mask: np.ndarray) -> "ReadBatch":
1059
+ """Devuelve un nuevo ReadBatch con solo las lecturas de ``mask``."""
1060
+ mask = np.asarray(mask, dtype=bool)
1061
+ if mask.shape[0] != len(self):
1062
+ raise SequenceValueError(
1063
+ f"la máscara tiene {mask.shape[0]} elementos pero el lote "
1064
+ f"tiene {len(self)} lecturas."
1065
+ )
1066
+ idx = np.flatnonzero(mask)
1067
+ new_n = self._n_syms[idx].copy()
1068
+ new_t = self._types[idx].copy()
1069
+ new_hdr, new_hoff = _gather_headers(self._hdr_raw, self._hdr_off, idx)
1070
+
1071
+ if self._fixed_len:
1072
+ # Todo es 2-D regular → indexación pura, sin bucles.
1073
+ L = self._fixed_len
1074
+ plen = (L * 5 + 7) // 8
1075
+ m = len(self)
1076
+ packed2d = self._packed[: m * plen].reshape(m, plen)
1077
+ new_packed = packed2d[idx].reshape(-1).copy()
1078
+ new_poff = (np.arange(len(idx) + 1, dtype=np.int32) * plen)
1079
+ new_qual = self._qual[idx].copy()
1080
+ return ReadBatch(new_packed, new_poff, new_n, new_t,
1081
+ new_hdr, new_hoff, new_qual, None, L)
1082
+
1083
+ # Irregular: reunir slices de los supervivientes (bucle por registro).
1084
+ pack_parts, qual_parts = [], []
1085
+ new_poff = np.empty(len(idx) + 1, dtype=np.int32)
1086
+ new_qoff = np.empty(len(idx) + 1, dtype=np.int32)
1087
+ new_poff[0] = new_qoff[0] = 0
1088
+ pcur = qcur = 0
1089
+ for k, j in enumerate(idx):
1090
+ ps = self._packed[int(self._pack_off[j]): int(self._pack_off[j + 1])]
1091
+ qs = self._qual[int(self._qual_off[j]): int(self._qual_off[j + 1])]
1092
+ pack_parts.append(ps); qual_parts.append(qs)
1093
+ pcur += ps.shape[0]; qcur += qs.shape[0]
1094
+ new_poff[k + 1] = pcur; new_qoff[k + 1] = qcur
1095
+ new_packed = (np.concatenate(pack_parts) if pack_parts
1096
+ else np.empty(0, dtype=np.uint8))
1097
+ new_qual = (np.concatenate(qual_parts) if qual_parts
1098
+ else np.empty(0, dtype=np.uint8))
1099
+ return ReadBatch(new_packed, new_poff, new_n, new_t,
1100
+ new_hdr, new_hoff, new_qual, new_qoff, 0)
1101
+
1102
+ # ── Acceso por registro (materializa el objeto solo aquí) ───────────────
1103
+ def header(self, i: int) -> str:
1104
+ i = int(i)
1105
+ return self._hdr_raw[
1106
+ int(self._hdr_off[i]): int(self._hdr_off[i + 1]) - 1
1107
+ ].decode("ascii", errors="replace")
1108
+
1109
+ def quality_of(self, i: int) -> np.ndarray:
1110
+ """Calidades Phred de la lectura ``i`` (copia uint8)."""
1111
+ i = int(i)
1112
+ if self._fixed_len:
1113
+ return self._qual[i].copy()
1114
+ return self._qual[
1115
+ int(self._qual_off[i]): int(self._qual_off[i + 1])
1116
+ ].copy()
1117
+
1118
+ def __getitem__(self, i: int) -> FastqRecord:
1119
+ n = len(self)
1120
+ i = int(i)
1121
+ if i < 0:
1122
+ i += n
1123
+ if not 0 <= i < n:
1124
+ raise IndexError(f"índice {i} fuera de rango (lote de {n})")
1125
+ seq = PackedSequence(
1126
+ header = self.header(i),
1127
+ seq_type = SeqType(int(self._types[i])),
1128
+ n_symbols = int(self._n_syms[i]),
1129
+ data = self._packed[
1130
+ int(self._pack_off[i]): int(self._pack_off[i + 1])
1131
+ ].copy(),
1132
+ )
1133
+ return FastqRecord(sequence=seq, quality=self.quality_of(i))
1134
+
1135
+ def __iter__(self) -> "Iterator[FastqRecord]":
1136
+ for i in range(len(self)):
1137
+ yield self[i]
1138
+
1139
+ def __repr__(self) -> str:
1140
+ kind = f"fixed L={self._fixed_len}" if self._fixed_len else "ragged"
1141
+ return (f"ReadBatch(m={len(self)}, "
1142
+ f"bases={int(self._n_syms.sum()):,}, {kind})")
1143
+
1144
+
1145
+ # ══════════════════════════════════════════════════════════════════════════════
1146
+ # §6 SMART IMPORTER — FASTA/FASTQ parser and 5-bit encoder
1147
+ # ══════════════════════════════════════════════════════════════════════════════
1148
+
1149
+ class SmartImporter:
1150
+ """
1151
+ Parse FASTA text and immediately encode sequences into 5-bit packed
1152
+ :class:`PackedSequence` objects.
1153
+
1154
+ The raw sequence ``str`` exists **only** inside :meth:`_encode` as a
1155
+ local variable released on return. No biological sequence data
1156
+ persists as a Python ``str`` after that call.
1157
+
1158
+ Sequence-type auto-detection
1159
+ ────────────────────────────
1160
+ The importer scans raw ASCII byte values for characters that are
1161
+ exclusive to protein sequences and absent from the IUPAC nucleotide
1162
+ alphabet ``{A C G T U R Y S W K M B D H V N - .}``::
1163
+
1164
+ E F I L P Q * (and their lowercase equivalents)
1165
+
1166
+ Any match → ``PROTEIN``; no match → ``NUCLEOTIDE``.
1167
+ Override per-call with the ``force_type`` parameter.
1168
+
1169
+ Quick start
1170
+ ───────────
1171
+ >>> records = SmartImporter.from_string(fasta_text)
1172
+ >>> records = SmartImporter.from_file("genome.fa")
1173
+ >>> for rec in SmartImporter.from_file_chunked("large.fa"):
1174
+ ... process(rec)
1175
+ """
1176
+
1177
+ @classmethod
1178
+ def from_string(
1179
+ cls,
1180
+ fasta: str,
1181
+ force_type: Optional[SeqType] = None,
1182
+ ) -> list[PackedSequence]:
1183
+ """
1184
+ Parse a complete FASTA string.
1185
+
1186
+ Parameters
1187
+ ----------
1188
+ fasta : str
1189
+ FASTA-formatted text (one or more ``>``-delimited records).
1190
+ force_type : SeqType, optional
1191
+ Skip auto-detection and force this type for all records.
1192
+
1193
+ Returns
1194
+ -------
1195
+ list[PackedSequence]
1196
+ One object per FASTA record, in input order.
1197
+ """
1198
+ return list(cls._iter_records(fasta, force_type))
1199
+
1200
+ @classmethod
1201
+ def from_file(
1202
+ cls,
1203
+ path: str,
1204
+ force_type: Optional[SeqType] = None,
1205
+ ) -> list[PackedSequence]:
1206
+ """
1207
+ Load and parse an entire FASTA file into memory.
1208
+
1209
+ Parameters
1210
+ ----------
1211
+ path : str
1212
+ Path to the FASTA file (ASCII or UTF-8 with ASCII sequences).
1213
+ force_type : SeqType, optional
1214
+ Override auto-detection for all records.
1215
+
1216
+ Returns
1217
+ -------
1218
+ list[PackedSequence]
1219
+ """
1220
+ with open(path, "r", encoding="ascii", errors="replace") as fh:
1221
+ return cls.from_string(fh.read(), force_type)
1222
+
1223
+ @classmethod
1224
+ def from_file_chunked(
1225
+ cls,
1226
+ path: str,
1227
+ force_type: Optional[SeqType] = None,
1228
+ ) -> Iterator[PackedSequence]:
1229
+ """
1230
+ Lazy generator for genome-scale files that do not fit in RAM.
1231
+
1232
+ Only one record's raw lines are held in memory at a time.
1233
+ Each record is encoded and yielded before the next is read.
1234
+
1235
+ Parameters
1236
+ ----------
1237
+ path : str
1238
+ Path to the FASTA file.
1239
+ force_type : SeqType, optional
1240
+ Override auto-detection.
1241
+
1242
+ Yields
1243
+ ------
1244
+ PackedSequence — one per FASTA record, in file order.
1245
+ """
1246
+ header: Optional[str] = None
1247
+ chunks: list[str] = []
1248
+
1249
+ with open(path, "r", encoding="ascii", errors="replace") as fh:
1250
+ for raw_line in fh:
1251
+ line = raw_line.strip()
1252
+ if line.startswith(">"):
1253
+ if header is not None and chunks:
1254
+ yield cls._encode("".join(chunks), header, force_type)
1255
+ header = line[1:]
1256
+ chunks = []
1257
+ elif line and not line.startswith(";"): # skip FASTA comments
1258
+ chunks.append(line)
1259
+
1260
+ if header is not None and chunks:
1261
+ yield cls._encode("".join(chunks), header, force_type)
1262
+
1263
+ # ── Streaming API (O(1) RAM, motor C con buffer 64 KB) ───────────────────
1264
+
1265
+ _STREAM_HDR = 4096 # bytes máximos para una cabecera FASTA/FASTQ
1266
+ _STREAM_SEQ = 16 * 1024 * 1024 # 16 MB — cubre lecturas Nanopore largas
1267
+
1268
+ # ── Modo por lotes (batch) — el camino rápido de v2.0 ────────────────────
1269
+ # Una sola llamada a C parsea miles de registros y los empaqueta a 5-bit
1270
+ # dentro de C. Elimina el peaje ctypes y el pack NumPy por registro.
1271
+ _BATCH_RECORDS = 8192 # registros por llamada
1272
+ _BATCH_HDR = 2 * 1024 * 1024 # 2 MB para cabeceras concatenadas
1273
+ _BATCH_PACK = 16 * 1024 * 1024 # 16 MB de secuencias 5-bit (≤25 Mbp/lote)
1274
+
1275
+ # ── Parser paralelo (OpenMP) — la vía de máximo rendimiento (FASTQ plano) ──
1276
+ _PWINDOW = 32 * 1024 * 1024 # ventana (32 MB): amortiza copias y OpenMP
1277
+ _PBUF = 48 * 1024 * 1024 # buffers de salida (1.5× ventana, seguro)
1278
+ _PMAXREC = 4_000_000 # registros máx. por ventana
1279
+ # La vía rápida .gz descomprime el archivo entero en RAM. Por encima de este
1280
+ # tamaño COMPRIMIDO se cae a la ruta secuencial (zlib, RAM constante) para no
1281
+ # agotar memoria con archivos enormes.
1282
+ _PGZ_MAX_COMPRESSED = 512 * 1024 * 1024 # 512 MB
1283
+
1284
+ @classmethod
1285
+ def stream(
1286
+ cls,
1287
+ path: str,
1288
+ force_type: Optional[SeqType] = None,
1289
+ ) -> Iterator[PackedSequence]:
1290
+ """
1291
+ Generador de bajo consumo de RAM para archivos FASTA de cualquier tamaño.
1292
+
1293
+ Cuando el motor C está disponible (compilado) usa el parser C con
1294
+ buffer de 64 KB y encoding 5-bit directo en C — sin strings Python
1295
+ en la ruta crítica. Fallback automático a :meth:`from_file_chunked`
1296
+ si el motor C no está presente.
1297
+
1298
+ Parameters
1299
+ ----------
1300
+ path : str
1301
+ Ruta al archivo FASTA.
1302
+ force_type : SeqType, optional
1303
+ Fuerza el tipo para todos los registros (omite auto-detección).
1304
+
1305
+ Yields
1306
+ ------
1307
+ PackedSequence — un objeto por registro, en orden de fichero.
1308
+
1309
+ Example
1310
+ -------
1311
+ >>> for seq in SmartImporter.stream("genome.fa"):
1312
+ ... print(seq.n_symbols)
1313
+ """
1314
+ ft = -1
1315
+ if force_type == SeqType.NUCLEOTIDE: ft = 0
1316
+ elif force_type == SeqType.PROTEIN: ft = 1
1317
+
1318
+ if _C_BATCH_AVAILABLE:
1319
+ yield from cls._stream_batch(path, ft, fastq=False)
1320
+ return
1321
+
1322
+ if not _C_PARSER_AVAILABLE:
1323
+ yield from cls.from_file_chunked(path, force_type)
1324
+ return
1325
+
1326
+ hdr_buf = ctypes.create_string_buffer(cls._STREAM_HDR)
1327
+ codes_buf = np.empty(cls._STREAM_SEQ, dtype=np.uint8)
1328
+
1329
+ handle = _c_parser_open(path)
1330
+ if not handle:
1331
+ raise BioForgeIOError(f"No se puede abrir el archivo: {path!r}")
1332
+
1333
+ try:
1334
+ while True:
1335
+ ret, n, stype = _c_parser_next(handle, hdr_buf, codes_buf, ft)
1336
+ if ret <= 0:
1337
+ break
1338
+ packed = BitPacker.pack(codes_buf[:n])
1339
+ header = hdr_buf.value.decode("ascii", errors="replace")
1340
+ yield PackedSequence(
1341
+ header = header,
1342
+ seq_type = SeqType(stype),
1343
+ n_symbols = n,
1344
+ data = packed,
1345
+ )
1346
+ finally:
1347
+ _c_parser_close(handle)
1348
+
1349
+ @classmethod
1350
+ def _stream_batch(cls, path: str, force_type: int, fastq: bool):
1351
+ """Núcleo del modo por lotes — compartido por stream() y stream_fastq().
1352
+
1353
+ C parsea hasta ``_BATCH_RECORDS`` registros por llamada y empaqueta cada
1354
+ secuencia a 5-bit. Aquí solo cruzamos la frontera ~N/8192 veces y
1355
+ creamos los objetos Python a partir de buffers ya empaquetados.
1356
+
1357
+ Yields PackedSequence (FASTA) o FastqRecord (FASTQ).
1358
+ """
1359
+ BR = cls._BATCH_RECORDS
1360
+ hdr_buf = ctypes.create_string_buffer(cls._BATCH_HDR)
1361
+ pack_buf = np.empty(cls._BATCH_PACK, dtype=np.uint8)
1362
+ hdr_off = np.empty(BR + 1, dtype=np.int32)
1363
+ pack_off = np.empty(BR + 1, dtype=np.int32)
1364
+ n_syms = np.empty(BR, dtype=np.int32)
1365
+ types = np.empty(BR, dtype=np.int32)
1366
+ if fastq:
1367
+ qual_buf = np.empty(cls._BATCH_PACK, dtype=np.uint8)
1368
+ qual_off = np.empty(BR + 1, dtype=np.int32)
1369
+ else:
1370
+ qual_buf = qual_off = None
1371
+
1372
+ handle = _c_parser_open(path)
1373
+ if not handle:
1374
+ raise BioForgeIOError(f"No se puede abrir el archivo: {path!r}")
1375
+
1376
+ ps = PackedSequence # alias local — menos lookups en el bucle
1377
+ try:
1378
+ while True:
1379
+ m = _c_parser_next_batch(
1380
+ handle, BR, force_type,
1381
+ hdr_buf, hdr_off, pack_buf, pack_off,
1382
+ n_syms, types, qual_buf, qual_off,
1383
+ )
1384
+ if m == 0:
1385
+ break
1386
+ if m < 0:
1387
+ raise EngineError(
1388
+ f"Error del parser por lotes (código {m}) en {path!r}. "
1389
+ "Código -2 = un registro supera el buffer de 16 MB; "
1390
+ "usa una herramienta de lecturas ultra-largas."
1391
+ )
1392
+ # Snapshot de los buffers como bytes/listas: una copia por lote,
1393
+ # no por registro. string_at copia solo los bytes usados, no los
1394
+ # 2 MB completos del buffer de cabeceras.
1395
+ hdr_used = int(hdr_off[m])
1396
+ hraw = ctypes.string_at(ctypes.addressof(hdr_buf), hdr_used)
1397
+ hoff = hdr_off[: m + 1].tolist()
1398
+ poff = pack_off[: m + 1].tolist()
1399
+ nlst = n_syms[:m].tolist()
1400
+ tlst = types[:m].tolist()
1401
+ qoff = qual_off[: m + 1].tolist() if fastq else None
1402
+
1403
+ for i in range(m):
1404
+ header = hraw[hoff[i]: hoff[i + 1] - 1].decode(
1405
+ "ascii", errors="replace")
1406
+ seq = ps(
1407
+ header = header,
1408
+ seq_type = SeqType(tlst[i]),
1409
+ n_symbols = nlst[i],
1410
+ data = pack_buf[poff[i]: poff[i + 1]].copy(),
1411
+ )
1412
+ if fastq:
1413
+ yield FastqRecord(
1414
+ sequence = seq,
1415
+ quality = qual_buf[qoff[i]: qoff[i + 1]].copy(),
1416
+ )
1417
+ else:
1418
+ yield seq
1419
+ finally:
1420
+ _c_parser_close(handle)
1421
+
1422
+ # ── Núcleo columnar (la vía rápida de v2.0) ──────────────────────────────
1423
+ @classmethod
1424
+ def _stream_columnar(cls, path: str, force_type: int, fastq: bool):
1425
+ """Entrega lotes como matrices contiguas (SequenceBatch / ReadBatch).
1426
+
1427
+ Cero objetos por registro: se conservan las matrices que C ya produce.
1428
+ Una copia por lote (no por registro) las desacopla de los buffers
1429
+ reutilizados. Yields SequenceBatch (FASTA) o ReadBatch (FASTQ).
1430
+ """
1431
+ if not _C_BATCH_AVAILABLE:
1432
+ yield from cls._columnar_fallback(path, force_type, fastq)
1433
+ return
1434
+
1435
+ BR = cls._BATCH_RECORDS
1436
+ hdr_buf = ctypes.create_string_buffer(cls._BATCH_HDR)
1437
+ pack_buf = np.empty(cls._BATCH_PACK, dtype=np.uint8)
1438
+ hdr_off = np.empty(BR + 1, dtype=np.int32)
1439
+ pack_off = np.empty(BR + 1, dtype=np.int32)
1440
+ n_syms = np.empty(BR, dtype=np.int32)
1441
+ types = np.empty(BR, dtype=np.int32)
1442
+ if fastq:
1443
+ qual_buf = np.empty(cls._BATCH_PACK, dtype=np.uint8)
1444
+ qual_off = np.empty(BR + 1, dtype=np.int32)
1445
+ else:
1446
+ qual_buf = qual_off = None
1447
+
1448
+ handle = _c_parser_open(path)
1449
+ if not handle:
1450
+ raise BioForgeIOError(f"No se puede abrir el archivo: {path!r}")
1451
+
1452
+ try:
1453
+ while True:
1454
+ m = _c_parser_next_batch(
1455
+ handle, BR, force_type,
1456
+ hdr_buf, hdr_off, pack_buf, pack_off,
1457
+ n_syms, types, qual_buf, qual_off,
1458
+ )
1459
+ if m == 0:
1460
+ break
1461
+ if m < 0:
1462
+ raise EngineError(
1463
+ f"Error del parser por lotes (código {m}) en {path!r}. "
1464
+ "Código -2 = un registro supera el buffer de 16 MB."
1465
+ )
1466
+ yield _columnar_batch(m, pack_buf, pack_off, n_syms, types,
1467
+ hdr_buf, hdr_off, qual_buf, qual_off, fastq)
1468
+ finally:
1469
+ _c_parser_close(handle)
1470
+
1471
+ # ── Camino paralelo (OpenMP) — máximo rendimiento en FASTA/FASTQ plano ───
1472
+ @staticmethod
1473
+ def _is_plain(path: str) -> bool:
1474
+ """True si el archivo NO está comprimido en gzip (mira el magic)."""
1475
+ try:
1476
+ with open(path, "rb") as f:
1477
+ return f.read(2) != b"\x1f\x8b"
1478
+ except OSError:
1479
+ return False
1480
+
1481
+ @staticmethod
1482
+ def _resolve_threads(n_threads: int) -> int:
1483
+ if n_threads <= 0:
1484
+ return os.cpu_count() or 1
1485
+ return n_threads
1486
+
1487
+ @classmethod
1488
+ def _use_parallel(cls, path: str, n_threads: int) -> bool:
1489
+ return (_C_PARALLEL_AVAILABLE and n_threads != 1
1490
+ and cls._is_plain(path))
1491
+
1492
+ @classmethod
1493
+ def _use_gz_fast(cls, path: str, n_threads: int) -> bool:
1494
+ return (_C_LIBDEFLATE_AVAILABLE and _C_PARALLEL_AVAILABLE
1495
+ and n_threads != 1 and not cls._is_plain(path))
1496
+
1497
+ @staticmethod
1498
+ def _boundary_before(mm, fastq: bool, lo: int, hi: int) -> int:
1499
+ """Mayor inicio de registro en (lo, hi]; ``lo`` si no hay ninguno.
1500
+
1501
+ Opera sobre el mmap sin copiar (rfind/find in situ). Para FASTQ verifica
1502
+ la 3ª línea '+' para no confundir un '@' de calidad.
1503
+ """
1504
+ if not fastq:
1505
+ i = mm.rfind(b"\n>", lo, hi)
1506
+ return i + 1 if i != -1 else lo
1507
+ sp = hi
1508
+ while True:
1509
+ i = mm.rfind(b"\n@", lo, sp)
1510
+ if i == -1:
1511
+ return lo
1512
+ rec = i + 1
1513
+ nl1 = mm.find(b"\n", rec, hi)
1514
+ if nl1 == -1:
1515
+ sp = i; continue
1516
+ nl2 = mm.find(b"\n", nl1 + 1, hi)
1517
+ if nl2 == -1:
1518
+ sp = i; continue
1519
+ if mm[nl2 + 1: nl2 + 2] == b"+":
1520
+ return rec
1521
+ sp = i
1522
+
1523
+ @classmethod
1524
+ def _parse_buffer_windows(cls, buf, arr, size: int,
1525
+ force_type: int, fastq: bool, nt: int):
1526
+ """Núcleo del parseo paralelo: trocea ``arr`` (vista de ``buf``) en
1527
+ ventanas alineadas a registro y reparte cada una entre ``nt`` hilos.
1528
+
1529
+ ``buf`` soporta rfind/find/slicing (mmap o bytearray); ``arr`` es su
1530
+ vista NumPy uint8. Yields SequenceBatch / ReadBatch.
1531
+ """
1532
+ if size == 0:
1533
+ return
1534
+ fmt = 2 if fastq else 1
1535
+ WIN = cls._PWINDOW
1536
+ MR = cls._PMAXREC
1537
+ start_char = ord("@") if fastq else ord(">")
1538
+ hdr_buf = ctypes.create_string_buffer(cls._PBUF)
1539
+ pack_buf = np.empty(cls._PBUF, dtype=np.uint8)
1540
+ hdr_off = np.empty(MR + 1, dtype=np.int32)
1541
+ pack_off = np.empty(MR + 1, dtype=np.int32)
1542
+ n_syms = np.empty(MR, dtype=np.int32)
1543
+ types = np.empty(MR, dtype=np.int32)
1544
+ if fastq:
1545
+ qual_buf = np.empty(cls._PBUF, dtype=np.uint8)
1546
+ qual_off = np.empty(MR + 1, dtype=np.int32)
1547
+ else:
1548
+ qual_buf = qual_off = None
1549
+
1550
+ pos = 0 if arr[0] == start_char else \
1551
+ cls._boundary_before(buf, fastq, 0, size)
1552
+ while pos < size:
1553
+ end = pos + WIN
1554
+ if end >= size:
1555
+ block_end = size
1556
+ else:
1557
+ block_end = cls._boundary_before(buf, fastq, pos, end)
1558
+ if block_end <= pos: # ventana < 1 registro: crecer
1559
+ grow = end
1560
+ while block_end <= pos and grow < size:
1561
+ grow = min(grow * 2, size)
1562
+ block_end = cls._boundary_before(buf, fastq, pos, grow)
1563
+ if block_end <= pos:
1564
+ block_end = size
1565
+ block = arr[pos:block_end] # vista sin copia
1566
+ m = _c_parse_mem_parallel(
1567
+ block, fmt, nt, force_type,
1568
+ hdr_buf, hdr_off, pack_buf, pack_off,
1569
+ n_syms, types, qual_buf, qual_off, MR)
1570
+ if m < 0:
1571
+ raise EngineError(
1572
+ f"Parser paralelo: código {m} (ventana demasiado densa o "
1573
+ "registro gigante; usa n_threads=1).")
1574
+ if m > 0:
1575
+ yield _columnar_batch(
1576
+ m, pack_buf, pack_off, n_syms, types,
1577
+ hdr_buf, hdr_off, qual_buf, qual_off, fastq)
1578
+ block = None # soltar la vista
1579
+ pos = block_end
1580
+
1581
+ @classmethod
1582
+ def _stream_parallel(cls, path: str, force_type: int, fastq: bool,
1583
+ n_threads: int):
1584
+ """FASTA/FASTQ plano: mmap + parseo paralelo por ventanas (sin copia)."""
1585
+ nt = cls._resolve_threads(n_threads)
1586
+ size = os.path.getsize(path)
1587
+ if size == 0:
1588
+ return
1589
+ with open(path, "rb") as fh:
1590
+ mm = mmap.mmap(fh.fileno(), 0, access=mmap.ACCESS_READ)
1591
+ arr = None
1592
+ try:
1593
+ arr = np.frombuffer(mm, dtype=np.uint8)
1594
+ yield from cls._parse_buffer_windows(
1595
+ mm, arr, size, force_type, fastq, nt)
1596
+ finally:
1597
+ arr = None # liberar el export del mmap antes de cerrar
1598
+ mm.close()
1599
+
1600
+ @classmethod
1601
+ def _stream_gz_parallel(cls, path: str, force_type: int, fastq: bool,
1602
+ n_threads: int):
1603
+ """.gz: descomprime entero con libdeflate (~2× zlib) y parsea en paralelo.
1604
+
1605
+ Si el tamaño es inesperado (gzip multi-miembro, etc.) cae a la ruta
1606
+ secuencial con zlib (RAM constante) sin fallar.
1607
+ """
1608
+ import struct
1609
+ nt = cls._resolve_threads(n_threads)
1610
+ # Archivos comprimidos enormes → ruta secuencial (RAM constante), para no
1611
+ # cargar todo el archivo descomprimido en memoria.
1612
+ if os.path.getsize(path) > cls._PGZ_MAX_COMPRESSED:
1613
+ yield from cls._stream_columnar(path, force_type, fastq)
1614
+ return
1615
+ with open(path, "rb") as fh:
1616
+ comp = fh.read()
1617
+ if len(comp) < 18: # gzip mínimo
1618
+ yield from cls._stream_columnar(path, force_type, fastq)
1619
+ return
1620
+ cbuf = np.frombuffer(comp, dtype=np.uint8)
1621
+
1622
+ # ── Palanca 3: BGZF (gzip por bloques) → descompresión PARALELA ──────
1623
+ if _c_is_bgzf(cbuf):
1624
+ usize = _c_bgzf_usize(cbuf)
1625
+ if usize > 0:
1626
+ out = bytearray(usize)
1627
+ oarr = np.frombuffer(out, dtype=np.uint8)
1628
+ n = _c_bgzf_decompress_parallel(cbuf, oarr, nt)
1629
+ if n >= 0:
1630
+ yield from cls._parse_buffer_windows(
1631
+ out, oarr, n, force_type, fastq, nt)
1632
+ return
1633
+ # si algo falla, sigue al camino gzip normal
1634
+
1635
+ # ── Palanca 2: gzip normal → libdeflate (1 hilo) ────────────────────
1636
+ isize = struct.unpack("<I", comp[-4:])[0] # tamaño descomprimido (mod 2^32)
1637
+ if isize == 0:
1638
+ yield from cls._stream_columnar(path, force_type, fastq)
1639
+ return
1640
+ out = bytearray(isize)
1641
+ oarr = np.frombuffer(out, dtype=np.uint8)
1642
+ n = _c_gzip_decompress(cbuf, oarr)
1643
+ if n < 0: # tamaño/forma inesperada → fallback
1644
+ yield from cls._stream_columnar(path, force_type, fastq)
1645
+ return
1646
+ yield from cls._parse_buffer_windows(out, oarr, n, force_type, fastq, nt)
1647
+
1648
+ @classmethod
1649
+ def _columnar_fallback(cls, path: str, force_type: int, fastq: bool):
1650
+ """Construye lotes columnares desde el generador por registro.
1651
+
1652
+ Solo se usa si el motor C por lotes no está disponible (DLL antiguo o
1653
+ sin compilar). Más lento, pero produce idénticos SequenceBatch/ReadBatch.
1654
+ """
1655
+ BR = cls._BATCH_RECORDS
1656
+ ft = (SeqType.NUCLEOTIDE if force_type == 0 else
1657
+ SeqType.PROTEIN if force_type == 1 else None)
1658
+ gen = (cls.stream_fastq(path) if fastq else cls.stream(path, ft))
1659
+ buf: list = []
1660
+ for item in gen:
1661
+ buf.append(item)
1662
+ if len(buf) >= BR:
1663
+ yield cls._assemble_batch(buf, fastq)
1664
+ buf = []
1665
+ if buf:
1666
+ yield cls._assemble_batch(buf, fastq)
1667
+
1668
+ @staticmethod
1669
+ def _assemble_batch(items: list, fastq: bool):
1670
+ """Ensambla una lista de PackedSequence/FastqRecord en un lote columnar."""
1671
+ m = len(items)
1672
+ seqs = [(it.sequence if fastq else it) for it in items]
1673
+ nsy = np.array([s.n_symbols for s in seqs], dtype=np.int32)
1674
+ tps = np.array([int(s.seq_type) for s in seqs], dtype=np.int32)
1675
+
1676
+ pack_parts = [np.asarray(s.data, dtype=np.uint8) for s in seqs]
1677
+ poff = np.empty(m + 1, dtype=np.int32)
1678
+ poff[0] = 0
1679
+ acc = 0
1680
+ for k, p in enumerate(pack_parts):
1681
+ acc += p.shape[0]; poff[k + 1] = acc
1682
+ packed = (np.concatenate(pack_parts) if pack_parts
1683
+ else np.empty(0, dtype=np.uint8))
1684
+
1685
+ hdr_parts = [s.header.encode("ascii", "replace") + b"\0" for s in seqs]
1686
+ hoff = np.empty(m + 1, dtype=np.int32)
1687
+ hoff[0] = 0
1688
+ acc = 0
1689
+ for k, h in enumerate(hdr_parts):
1690
+ acc += len(h); hoff[k + 1] = acc
1691
+ hraw = b"".join(hdr_parts)
1692
+
1693
+ if not fastq:
1694
+ return SequenceBatch(packed, poff, nsy, tps, hraw, hoff)
1695
+
1696
+ quals = [np.asarray(it.quality, dtype=np.uint8) for it in items]
1697
+ fixed = (int(nsy[0]) if (m > 0 and nsy[0] > 0
1698
+ and bool(np.all(nsy == nsy[0]))) else 0)
1699
+ if fixed:
1700
+ qual = (np.stack(quals) if quals
1701
+ else np.empty((0, fixed), dtype=np.uint8))
1702
+ qoff = None
1703
+ else:
1704
+ qual = (np.concatenate(quals) if quals
1705
+ else np.empty(0, dtype=np.uint8))
1706
+ qoff = np.empty(m + 1, dtype=np.int32)
1707
+ qoff[0] = 0
1708
+ acc = 0
1709
+ for k, q in enumerate(quals):
1710
+ acc += q.shape[0]; qoff[k + 1] = acc
1711
+ return ReadBatch(packed, poff, nsy, tps, hraw, hoff, qual, qoff, fixed)
1712
+
1713
+ @classmethod
1714
+ def stream_batches(
1715
+ cls,
1716
+ path: str,
1717
+ force_type: Optional[SeqType] = None,
1718
+ n_threads: int = 1,
1719
+ ) -> "Iterator[SequenceBatch]":
1720
+ """
1721
+ Lee un FASTA como lotes columnares :class:`SequenceBatch` (vía rápida).
1722
+
1723
+ Cada lote agrupa registros como matrices contiguas, sin crear un objeto
1724
+ por registro. Para acceder a un registro concreto usa ``batch[i]``.
1725
+
1726
+ Parameters
1727
+ ----------
1728
+ n_threads : int, default 1
1729
+ 1 = secuencial. >1 = nº de hilos. 0 = todos los núcleos (auto).
1730
+ El parseo paralelo (OpenMP) solo aplica a archivos **planos**
1731
+ (no `.gz`) cuando el motor C lo soporta; si no, cae a secuencial.
1732
+
1733
+ Example
1734
+ -------
1735
+ >>> for batch in SmartImporter.stream_batches("genome.fa", n_threads=0):
1736
+ ... print(len(batch), "secuencias,", int(batch.n_symbols.sum()), "bases")
1737
+ """
1738
+ ft = -1
1739
+ if force_type == SeqType.NUCLEOTIDE: ft = 0
1740
+ elif force_type == SeqType.PROTEIN: ft = 1
1741
+ if cls._use_parallel(path, n_threads):
1742
+ yield from cls._stream_parallel(path, ft, False, n_threads)
1743
+ elif cls._use_gz_fast(path, n_threads):
1744
+ yield from cls._stream_gz_parallel(path, ft, False, n_threads)
1745
+ else:
1746
+ yield from cls._stream_columnar(path, ft, fastq=False)
1747
+
1748
+ @classmethod
1749
+ def stream_fastq_batches(
1750
+ cls, path: str, n_threads: int = 1,
1751
+ ) -> "Iterator[ReadBatch]":
1752
+ """
1753
+ Lee un FASTQ como lotes columnares :class:`ReadBatch` — la vía rápida
1754
+ para control de calidad.
1755
+
1756
+ Filtrar por calidad media se vuelve una operación NumPy sobre todo el
1757
+ lote, sin fabricar un objeto por lectura.
1758
+
1759
+ Parameters
1760
+ ----------
1761
+ n_threads : int, default 1
1762
+ 1 = secuencial. >1 = nº de hilos. 0 = todos los núcleos (auto).
1763
+ El parseo paralelo solo aplica a FASTQ **plano** (no `.gz`).
1764
+
1765
+ Example
1766
+ -------
1767
+ >>> total = buenas = 0
1768
+ >>> for batch in SmartImporter.stream_fastq_batches("reads.fastq", n_threads=0):
1769
+ ... mask = batch.passes(20) # 1 op NumPy para miles de lecturas
1770
+ ... total += len(batch)
1771
+ ... buenas += int(mask.sum())
1772
+ >>> print(f"{buenas}/{total} lecturas con calidad media ≥ 20")
1773
+ """
1774
+ if cls._use_parallel(path, n_threads):
1775
+ yield from cls._stream_parallel(path, 0, True, n_threads)
1776
+ elif cls._use_gz_fast(path, n_threads):
1777
+ yield from cls._stream_gz_parallel(path, 0, True, n_threads)
1778
+ else:
1779
+ yield from cls._stream_columnar(path, 0, fastq=True)
1780
+
1781
+ @classmethod
1782
+ def stream_fastq(cls, path: str) -> Iterator[FastqRecord]:
1783
+ """
1784
+ Generador de bajo consumo de RAM para archivos FASTQ.
1785
+
1786
+ Cada ``FastqRecord`` contiene la secuencia 5-bit empaquetada y las
1787
+ calidades Phred (valor entero 0–93, ya restado el offset ASCII de 33).
1788
+
1789
+ Parameters
1790
+ ----------
1791
+ path : str
1792
+ Ruta al archivo FASTQ (no comprimido).
1793
+
1794
+ Yields
1795
+ ------
1796
+ FastqRecord — uno por lectura, en orden de fichero.
1797
+
1798
+ Example
1799
+ -------
1800
+ >>> for rec in SmartImporter.stream_fastq("reads.fastq"):
1801
+ ... if rec.passes_quality(20):
1802
+ ... prot = SmartTranslator.translate(rec.sequence)
1803
+ """
1804
+ if _C_BATCH_AVAILABLE:
1805
+ yield from cls._stream_batch(path, 0, fastq=True)
1806
+ return
1807
+
1808
+ if not _C_PARSER_AVAILABLE:
1809
+ yield from cls._stream_fastq_python(path)
1810
+ return
1811
+
1812
+ hdr_buf = ctypes.create_string_buffer(cls._STREAM_HDR)
1813
+ codes_buf = np.empty(cls._STREAM_SEQ, dtype=np.uint8)
1814
+ qual_buf = np.empty(cls._STREAM_SEQ, dtype=np.uint8)
1815
+
1816
+ handle = _c_parser_open(path)
1817
+ if not handle:
1818
+ raise BioForgeIOError(f"No se puede abrir el archivo: {path!r}")
1819
+
1820
+ try:
1821
+ while True:
1822
+ ret, n, q = _c_parser_next_fastq(
1823
+ handle, hdr_buf, codes_buf, qual_buf
1824
+ )
1825
+ if ret <= 0:
1826
+ break
1827
+ packed = BitPacker.pack(codes_buf[:n])
1828
+ header = hdr_buf.value.decode("ascii", errors="replace")
1829
+ seq = PackedSequence(
1830
+ header = header,
1831
+ seq_type = SeqType.NUCLEOTIDE,
1832
+ n_symbols = n,
1833
+ data = packed,
1834
+ )
1835
+ yield FastqRecord(
1836
+ sequence = seq,
1837
+ quality = qual_buf[:q].copy(),
1838
+ )
1839
+ finally:
1840
+ _c_parser_close(handle)
1841
+
1842
+ @classmethod
1843
+ def _stream_fastq_python(cls, path: str) -> Iterator[FastqRecord]:
1844
+ """Fallback Python puro para FASTQ (sin motor C)."""
1845
+ with open(path, "r", encoding="ascii", errors="replace") as fh:
1846
+ while True:
1847
+ line1 = fh.readline()
1848
+ if not line1:
1849
+ break
1850
+ line1 = line1.strip()
1851
+ if not line1.startswith("@"):
1852
+ continue
1853
+ header = line1[1:]
1854
+ seq_raw = fh.readline().strip()
1855
+ fh.readline() # línea '+comment'
1856
+ qual_raw = fh.readline().strip()
1857
+ if not seq_raw:
1858
+ continue
1859
+ seq = cls._encode(seq_raw, header, None)
1860
+ q = np.frombuffer(qual_raw.encode("ascii"), dtype=np.uint8)
1861
+ q = np.clip(q.astype(np.int16) - 33, 0, 93).astype(np.uint8)
1862
+ yield FastqRecord(sequence=seq, quality=q)
1863
+
1864
+ # ── Private helpers ───────────────────────────────────────────────────────
1865
+
1866
+ @classmethod
1867
+ def _iter_records(
1868
+ cls,
1869
+ fasta: str,
1870
+ force_type: Optional[SeqType],
1871
+ ) -> Iterator[PackedSequence]:
1872
+ """Yield one ``PackedSequence`` per record from a FASTA string."""
1873
+ header: Optional[str] = None
1874
+ chunks: list[str] = []
1875
+
1876
+ for line in fasta.splitlines():
1877
+ if line.startswith(">"):
1878
+ if header is not None and chunks:
1879
+ yield cls._encode("".join(chunks), header, force_type)
1880
+ header = line[1:]
1881
+ chunks = []
1882
+ elif line and not line.startswith(";"):
1883
+ chunks.append(line.strip())
1884
+
1885
+ if header is not None and chunks:
1886
+ yield cls._encode("".join(chunks), header, force_type)
1887
+
1888
+ @staticmethod
1889
+ def _detect_type(ascii_bytes: np.ndarray) -> SeqType:
1890
+ """
1891
+ Classify nucleotide vs protein from raw ASCII byte values.
1892
+
1893
+ ``_IS_PROTEIN_CHAR[ascii_bytes].any()`` performs the entire
1894
+ classification in one vectorised numpy call, with short-circuit
1895
+ semantics on the first protein-exclusive byte found.
1896
+
1897
+ Parameters
1898
+ ----------
1899
+ ascii_bytes : np.ndarray, dtype uint8
1900
+ ASCII ordinals of the (uppercased) raw sequence.
1901
+
1902
+ Returns
1903
+ -------
1904
+ SeqType
1905
+ """
1906
+ return (
1907
+ SeqType.PROTEIN
1908
+ if _IS_PROTEIN_CHAR[ascii_bytes].any()
1909
+ else SeqType.NUCLEOTIDE
1910
+ )
1911
+
1912
+ @staticmethod
1913
+ def _encode(
1914
+ raw_seq: str,
1915
+ header: str,
1916
+ force_type: Optional[SeqType] = None,
1917
+ ) -> PackedSequence:
1918
+ """
1919
+ Core encoding pipeline — **the only function that ever holds a
1920
+ biological sequence as a ``str``.** Four vectorised steps:
1921
+
1922
+ ① ``str`` → raw uint8 ASCII array (``np.frombuffer``, near-zero copy)
1923
+ ② ASCII → 5-bit BioCode array (single LUT fancy-index)
1924
+ ③ BioCode → packed uint8 byte array (``np.packbits``-based)
1925
+ ④ Wrap in :class:`PackedSequence` and return.
1926
+
1927
+ *raw_seq* and all intermediate arrays are local and released on return.
1928
+
1929
+ Parameters
1930
+ ----------
1931
+ raw_seq : Raw sequence text (mixed case / gaps accepted).
1932
+ header : FASTA description line without the ``>`` prefix.
1933
+ force_type : Override auto-detection.
1934
+
1935
+ Returns
1936
+ -------
1937
+ PackedSequence
1938
+ """
1939
+ # ① String → ASCII ordinal array (frombuffer avoids a full copy).
1940
+ # NUC_LUT and AA_LUT already map both upper and lower case, so
1941
+ # .upper() would waste one full string copy — skip it.
1942
+ ascii_bytes: np.ndarray = np.frombuffer(
1943
+ raw_seq.encode("ascii", errors="replace"),
1944
+ dtype=np.uint8,
1945
+ )
1946
+
1947
+ # ② Detect or apply sequence type
1948
+ seq_type: SeqType = (
1949
+ force_type
1950
+ if force_type is not None
1951
+ else SmartImporter._detect_type(ascii_bytes)
1952
+ )
1953
+
1954
+ # ③ Translate ASCII ordinals → 5-bit BioCode (single LUT index op)
1955
+ lut: np.ndarray = NUC_LUT if seq_type == SeqType.NUCLEOTIDE else AA_LUT
1956
+ codes: np.ndarray = lut[ascii_bytes] # shape (n,), dtype uint8
1957
+
1958
+ # ④ Compress 5-bit codes → packed byte array
1959
+ packed: np.ndarray = BitPacker.pack(codes)
1960
+
1961
+ return PackedSequence( # raw_seq released here ✓
1962
+ header = header,
1963
+ seq_type = seq_type,
1964
+ n_symbols = int(len(codes)),
1965
+ data = packed,
1966
+ )
1967
+
1968
+
1969
+ # ══════════════════════════════════════════════════════════════════════════════
1970
+ # §6 UTILITIES
1971
+ # ══════════════════════════════════════════════════════════════════════════════
1972
+
1973
+ @dataclass
1974
+ class SequenceStats:
1975
+ """Composition and storage statistics for a :class:`PackedSequence`."""
1976
+ n_symbols: int
1977
+ n_packed_bytes: int
1978
+ compression_pct: float # percent saved vs naive 8-bit ASCII
1979
+ composition: dict[str, int] # canonical IUPAC letter → symbol count
1980
+
1981
+
1982
+ def compute_stats(seq: PackedSequence) -> SequenceStats:
1983
+ """
1984
+ Compute composition and storage statistics for a ``PackedSequence``.
1985
+
1986
+ All counting is done via ``np.bincount`` — fully vectorised.
1987
+
1988
+ Parameters
1989
+ ----------
1990
+ seq : PackedSequence
1991
+
1992
+ Returns
1993
+ -------
1994
+ SequenceStats
1995
+ """
1996
+ codes = seq.decode() # uint8, shape (n,)
1997
+ dec_map = _NUC_DECODE if seq.seq_type == SeqType.NUCLEOTIDE else _AA_DECODE
1998
+ raw_counts = np.bincount(codes, minlength=32) # counts per BioCode
1999
+
2000
+ composition: dict[str, int] = {
2001
+ dec_map[code]: int(cnt)
2002
+ for code, cnt in enumerate(raw_counts)
2003
+ if cnt > 0 and code in dec_map
2004
+ }
2005
+
2006
+ return SequenceStats(
2007
+ n_symbols = seq.n_symbols,
2008
+ n_packed_bytes = seq.packed_bytes,
2009
+ compression_pct = (1.0 - seq.memory_ratio) * 100.0,
2010
+ composition = composition,
2011
+ )
2012
+
2013
+
2014
+ # ══════════════════════════════════════════════════════════════════════════════
2015
+ # §7 DEMO / SELF-TEST (run with: python biocore.py)
2016
+ # ══════════════════════════════════════════════════════════════════════════════
2017
+
2018
+ if __name__ == "__main__":
2019
+ import time
2020
+
2021
+ _DEMO_FASTA = """\
2022
+ >NC_000913.3 E. coli K-12 MG1655 — genomic fragment (DNA)
2023
+ ATGAAACGCATTAGCACCACCATTACCACCACCATCACCATTACCACAGGTAACGGTGCGGGCTGA
2024
+ CGCGTACAGGAAACAGCCAGCGATAAGTCCTGAATCAGCAAAAGCTTTTGCCCATCAGTTCAGTCA
2025
+ >sp|P68871|HBB_HUMAN Hemoglobin subunit beta OS=Homo sapiens
2026
+ MVHLTPEEKSAVTALWGKVNVDEVGGEALGRLLVVYPWTQRFFESFGDLSTPDAVMGNPKVKAHGK
2027
+ KVLGAFSDGLAHLDNLKGTFATLSELHCDKLHVDPENFRLLGNVLVCVLAHHFGKEFTPPVQAAYQ
2028
+ KVVAGVANALAHKYH*
2029
+ >sp|P69905|HBA_HUMAN Hemoglobin subunit alpha OS=Homo sapiens
2030
+ MVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADAT
2031
+ LNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLAAHLPAEFTPAVHASLDKFLASVS
2032
+ TVLTSKYR*
2033
+ """
2034
+
2035
+ W = 65
2036
+ print("═" * W)
2037
+ print(" BioForge — biocore.py — Unified 5-bit bioinformatics engine demo")
2038
+ print("═" * W)
2039
+
2040
+ records = SmartImporter.from_string(_DEMO_FASTA)
2041
+
2042
+ for rec in records:
2043
+ stats = compute_stats(rec)
2044
+ tag = rec.seq_type.name
2045
+ print(f"\n ── {tag} {'─' * (W - 6 - len(tag))}")
2046
+ print(f" Header : {rec.header[:55]}")
2047
+ print(f" Symbols : {stats.n_symbols:>10,}")
2048
+ print(f" Packed : {stats.n_packed_bytes:>10,} B")
2049
+ print(f" Naive (ASCII): {stats.n_symbols:>10,} B")
2050
+ print(f" Saved : {stats.compression_pct:>9.1f} %")
2051
+ print(f" Preview : {rec.to_string()[:45]!r}")
2052
+ comp = " ".join(f"{k}:{v}" for k, v in sorted(stats.composition.items()))
2053
+ print(f" Composition : {comp}")
2054
+
2055
+ # ── Round-trip integrity check ─────────────────────────────────────
2056
+ codes = rec.decode()
2057
+ repacked = BitPacker.pack(codes)
2058
+ assert np.array_equal(repacked, rec.data), "❌ Round-trip parity failure!"
2059
+ print(f" Round-trip : ✅ ({rec.n_symbols} sym → {rec.packed_bytes} B → OK)")
2060
+
2061
+ # ── Per-symbol O(1) access check ───────────────────────────────────
2062
+ n_check = min(10, rec.n_symbols)
2063
+ for i in range(n_check):
2064
+ assert rec[i] == int(codes[i]), f"❌ rec[{i}] mismatch"
2065
+ print(f" __getitem__ : ✅ (first {n_check} symbols checked, each O(1))")
2066
+
2067
+ # ── Micro-benchmark ────────────────────────────────────────────────────────
2068
+ print(f"\n{'═' * W}")
2069
+ print(" Micro-benchmark — 10 million random nucleotides")
2070
+ print(f"{'═' * W}")
2071
+
2072
+ N = 10_000_000
2073
+ rng = np.random.default_rng(seed=42)
2074
+ bench_codes = rng.integers(0, 4, size=N, dtype=np.uint8)
2075
+
2076
+ t0 = time.perf_counter()
2077
+ bench_packed = BitPacker.pack(bench_codes)
2078
+ t1 = time.perf_counter()
2079
+ bench_unpacked = BitPacker.unpack(bench_packed, N)
2080
+ t2 = time.perf_counter()
2081
+
2082
+ assert np.array_equal(bench_unpacked, bench_codes), "❌ Benchmark round-trip failed"
2083
+
2084
+ pack_ms = (t1 - t0) * 1e3
2085
+ unpack_ms = (t2 - t1) * 1e3
2086
+
2087
+ print(f" Symbols : {N:>12,}")
2088
+ print(f" Pack time : {pack_ms:>10.2f} ms ({N / pack_ms / 1e3:>7.0f} M sym/s)")
2089
+ print(f" Unpack time : {unpack_ms:>10.2f} ms ({N / unpack_ms / 1e3:>7.0f} M sym/s)")
2090
+ print(f" Packed size : {len(bench_packed):>12,} B (naive: {N:,} B)")
2091
+ print(f" Memory ratio : {len(bench_packed) / N:.4f} (ideal 5-bit: 0.6250)")
2092
+ print()