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,953 @@
1
+ """
2
+ aligner.py
3
+ ══════════════════════════════════════════════════════════════════════
4
+ Needleman-Wunsch pairwise sequence aligner — anti-diagonal wavefront.
5
+
6
+ Integrates with biocore.py / smart_translator.py (5-bit engine).
7
+
8
+ Vectorization strategy
9
+ ──────────────────────
10
+ The NW recurrence H[i,j] = max(H[i-1,j-1]+s, H[i-1,j]+g, H[i,j-1]+g)
11
+ carries a cell-level data dependency that prevents full 2-D NumPy
12
+ vectorisation. Cells on the same anti-diagonal (i+j = d) are however
13
+ mutually independent, enabling:
14
+
15
+ • ONE outer Python loop over anti-diagonals → O(m+n) iterations
16
+ • NumPy-vectorised computation inside each → up to min(m,n) ops
17
+
18
+ This reduces Python-level iterations from O(m·n) to O(m+n).
19
+
20
+ The traceback is inherently sequential (O(m+n)) — the data dependency
21
+ cannot be removed at that stage either.
22
+
23
+ Scoring (linear gap model)
24
+ ──────────────────────────
25
+ Match : +2
26
+ Mismatch : −1
27
+ Gap : −2 (uniform open+extend)
28
+
29
+ Memory constraint
30
+ ─────────────────
31
+ The full DP matrix is O(m·n) int32. For sequences > 15 000 symbols
32
+ a UserWarning is emitted; banded alignment (not yet implemented)
33
+ would be the correct solution at chromosome scale.
34
+
35
+ Quick start
36
+ ───────────
37
+ >>> from bioforge import SmartImporter, SeqType
38
+ >>> seqs = SmartImporter.from_string(fasta_a + fasta_b,
39
+ ... force_type=SeqType.NUCLEOTIDE)
40
+ >>> result = SequenceAligner.align(seqs[0], seqs[1])
41
+ >>> print(format_alignment(result))
42
+ >>> for mut in result.mutations:
43
+ ... print(mut)
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import warnings
49
+ from dataclasses import dataclass
50
+ from typing import Literal
51
+
52
+ import numpy as np
53
+
54
+ from .biocore import (
55
+ _AA_DECODE,
56
+ _AA_DECODE_ARR,
57
+ _NUC_DECODE,
58
+ _NUC_DECODE_ARR,
59
+ AlignmentError,
60
+ BitPacker,
61
+ PackedSequence,
62
+ SeqType,
63
+ SequenceTypeError,
64
+ SequenceValueError,
65
+ )
66
+
67
+ try:
68
+ from .engine._loader import C_AVAILABLE
69
+ from .engine._loader import c_nw_align as _c_nw_align
70
+ from .engine._loader import c_nw_banded as _c_nw_banded
71
+ from .engine._loader import c_sw_align as _c_sw_align
72
+ except ImportError:
73
+ C_AVAILABLE = False
74
+ _c_nw_align = None # type: ignore[assignment]
75
+ _c_sw_align = None # type: ignore[assignment]
76
+ _c_nw_banded = None # type: ignore[assignment]
77
+
78
+ # Bytes de decodificación para el motor C (32 bytes: BioCode -> ASCII)
79
+ _NUC_DECODE_BYTES: bytes = bytes(_NUC_DECODE_ARR)
80
+ _AA_DECODE_BYTES: bytes = bytes(_AA_DECODE_ARR)
81
+
82
+
83
+ __all__: list[str] = [
84
+ "Mutation",
85
+ "AlignmentResult",
86
+ "SequenceAligner",
87
+ "format_alignment",
88
+ ]
89
+
90
+
91
+ # ══════════════════════════════════════════════════════════════════════════════
92
+ # §1 DATA CLASSES
93
+ # ══════════════════════════════════════════════════════════════════════════════
94
+
95
+ @dataclass
96
+ class Mutation:
97
+ """
98
+ A single sequence variant between two aligned sequences.
99
+
100
+ Attributes
101
+ ──────────
102
+ kind : 'substitution' | 'deletion' | 'insertion'
103
+ *deletion* — seq_a has a symbol; seq_b has a gap.
104
+ *insertion* — seq_a has a gap; seq_b has a symbol.
105
+ pos_a : 0-based index in seq_a (gap-insertion point for 'insertion').
106
+ pos_b : 0-based index in seq_b (gap-insertion point for 'deletion').
107
+ sym_a : symbol in seq_a ('-' for insertions).
108
+ sym_b : symbol in seq_b ('-' for deletions).
109
+ """
110
+ kind: str
111
+ pos_a: int
112
+ pos_b: int
113
+ sym_a: str
114
+ sym_b: str
115
+
116
+ def __str__(self) -> str:
117
+ if self.kind == 'substitution':
118
+ return (
119
+ f"SUB a[{self.pos_a}]={self.sym_a!r} → "
120
+ f"b[{self.pos_b}]={self.sym_b!r}"
121
+ )
122
+ if self.kind == 'deletion':
123
+ return (
124
+ f"DEL a[{self.pos_a}]={self.sym_a!r} "
125
+ f"(deleción en seq_b, tras b[{self.pos_b}])"
126
+ )
127
+ return (
128
+ f"INS b[{self.pos_b}]={self.sym_b!r} "
129
+ f"(inserción en seq_b, gap en seq_a[{self.pos_a}])"
130
+ )
131
+
132
+
133
+ @dataclass
134
+ class AlignmentResult:
135
+ """Full result of a pairwise Needleman-Wunsch alignment."""
136
+ score: int
137
+ identity: float # n_matches / aligned_length
138
+ n_matches: int
139
+ n_mismatches: int
140
+ n_gaps: int # total gap characters in the aligned region
141
+ mutations: list[Mutation]
142
+ aligned_a: str # seq_a string with '-' at gap positions
143
+ aligned_b: str # seq_b string with '-' at gap positions
144
+ seq_type: SeqType
145
+ mode: str # 'global' or 'semi-global'
146
+
147
+
148
+ # ══════════════════════════════════════════════════════════════════════════════
149
+ # §2 SEQUENCE ALIGNER
150
+ # ══════════════════════════════════════════════════════════════════════════════
151
+
152
+ class SequenceAligner:
153
+ """
154
+ Needleman-Wunsch pairwise aligner with anti-diagonal (wavefront) strategy.
155
+
156
+ Scoring-matrix fill : O(m+n) Python iterations × NumPy inner ops.
157
+ Traceback : O(m+n) sequential Python loop (unavoidable).
158
+
159
+ Class constants
160
+ ───────────────
161
+ MATCH : np.int32 +2
162
+ MISMATCH : np.int32 −1
163
+ GAP : np.int32 −2 (linear gap model)
164
+ _MAX_SAFE_LEN : int 15 000 (DP matrix stays ≤ ~3.4 GB int32)
165
+ """
166
+
167
+ MATCH: np.int32 = np.int32( 2)
168
+ MISMATCH: np.int32 = np.int32(-1)
169
+ GAP: np.int32 = np.int32(-2)
170
+
171
+ _MAX_SAFE_LEN: int = 15_000
172
+
173
+ # ── Public API ─────────────────────────────────────────────────────────────
174
+
175
+ @classmethod
176
+ def align(
177
+ cls,
178
+ seq_a: PackedSequence,
179
+ seq_b: PackedSequence,
180
+ mode: Literal['global', 'semi-global'] = 'global',
181
+ band: int | None = None,
182
+ ) -> AlignmentResult:
183
+ """
184
+ Align seq_a (reference) against seq_b (query) — Needleman-Wunsch.
185
+
186
+ Parameters
187
+ ----------
188
+ seq_a : PackedSequence — reference.
189
+ seq_b : PackedSequence — query.
190
+ mode : 'global' — penalises all terminal gaps.
191
+ 'semi-global' — free terminal gaps on the query side.
192
+ band : int, optional
193
+ Half-width of the alignment band. When given, only cells
194
+ with ``|i - j| ≤ band`` are computed (banded NW).
195
+ Reduces memory from O(m·n) to O(m·band) with the C engine.
196
+ Required when sequences exceed ``_MAX_SAFE_LEN``.
197
+
198
+ Returns
199
+ -------
200
+ AlignmentResult
201
+
202
+ Raises
203
+ ------
204
+ TypeError — seq_a.seq_type ≠ seq_b.seq_type.
205
+ ValueError — either sequence is empty or band is too narrow.
206
+ """
207
+ if not isinstance(seq_a, PackedSequence):
208
+ raise SequenceTypeError(
209
+ f"seq_a debe ser PackedSequence, se recibió {type(seq_a).__name__!r}. "
210
+ "Crea la secuencia con SmartImporter.from_string() o SmartImporter.from_file()."
211
+ )
212
+ if not isinstance(seq_b, PackedSequence):
213
+ raise SequenceTypeError(
214
+ f"seq_b debe ser PackedSequence, se recibió {type(seq_b).__name__!r}. "
215
+ "Crea la secuencia con SmartImporter.from_string() o SmartImporter.from_file()."
216
+ )
217
+ if mode not in ('global', 'semi-global'):
218
+ raise AlignmentError(
219
+ f"mode debe ser 'global' o 'semi-global', se recibió {mode!r}."
220
+ )
221
+ if seq_a.seq_type != seq_b.seq_type:
222
+ raise SequenceTypeError(
223
+ f"Los tipos no coinciden: "
224
+ f"{seq_a.seq_type.name} ≠ {seq_b.seq_type.name}. "
225
+ "Ambas secuencias deben ser del mismo tipo (NUCLEOTIDE o PROTEIN)."
226
+ )
227
+ if seq_a.n_symbols == 0 or seq_b.n_symbols == 0:
228
+ raise SequenceValueError("Las secuencias no pueden estar vacías.")
229
+
230
+ m, n = seq_a.n_symbols, seq_b.n_symbols
231
+
232
+ codes_a = seq_a.decode() # (m,) uint8
233
+ codes_b = seq_b.decode() # (n,) uint8
234
+
235
+ # ── Banded NW path ─────────────────────────────────────────────────────
236
+ if band is not None:
237
+ if band < 1:
238
+ raise AlignmentError(f"band debe ser ≥ 1, se recibió {band}.")
239
+ if C_AVAILABLE:
240
+ return cls._align_banded_c(
241
+ codes_a, codes_b, m, n, band, mode, seq_a.seq_type
242
+ )
243
+ # NumPy fallback: banded fill sobre matriz completa
244
+ if m > cls._MAX_SAFE_LEN or n > cls._MAX_SAFE_LEN:
245
+ raise AlignmentError(
246
+ f"Secuencias > {cls._MAX_SAFE_LEN:,} síms con band= requieren "
247
+ "el motor C. Compila con: python bioforge/engine/build.py"
248
+ )
249
+ H = cls._fill_matrix_banded(codes_a, codes_b, m, n, band, mode)
250
+ return cls._traceback(H, codes_a, codes_b, m, n, mode, seq_a.seq_type)
251
+
252
+ # ── NW completo ────────────────────────────────────────────────────────
253
+ if m > cls._MAX_SAFE_LEN or n > cls._MAX_SAFE_LEN:
254
+ mem_mb = (m + 1) * (n + 1) * 4 / 1_000_000
255
+ warnings.warn(
256
+ f"Secuencia larga ({max(m, n):,} síms → "
257
+ f"matriz DP ≈ {mem_mb:.0f} MB). "
258
+ f"Usa band=<valor> para alineamiento por bandas.",
259
+ UserWarning,
260
+ stacklevel=2,
261
+ )
262
+
263
+ if C_AVAILABLE:
264
+ return cls._align_c(codes_a, codes_b, m, n, mode, seq_a.seq_type)
265
+
266
+ H = cls._fill_matrix(codes_a, codes_b, m, n, mode)
267
+ return cls._traceback(H, codes_a, codes_b, m, n, mode, seq_a.seq_type)
268
+
269
+ @classmethod
270
+ def align_local(
271
+ cls,
272
+ seq_a: PackedSequence,
273
+ seq_b: PackedSequence,
274
+ ) -> AlignmentResult:
275
+ """
276
+ Smith-Waterman local alignment — finds the best-scoring subsequence.
277
+
278
+ Unlike ``align()`` (global/semi-global NW), local alignment:
279
+ • Does not penalise unaligned ends.
280
+ • Finds the highest-scoring contiguous region.
281
+ • Returns ``mode='local'`` in the result.
282
+
283
+ Use when searching for a short motif inside a longer sequence,
284
+ or when sequences share only a conserved domain.
285
+
286
+ Parameters
287
+ ----------
288
+ seq_a : PackedSequence — reference (or the longer sequence).
289
+ seq_b : PackedSequence — query (or the motif to search for).
290
+
291
+ Returns
292
+ -------
293
+ AlignmentResult with ``mode='local'``.
294
+
295
+ Raises
296
+ ------
297
+ SequenceTypeError — seq_a.seq_type ≠ seq_b.seq_type.
298
+ SequenceValueError — either sequence is empty.
299
+ """
300
+ if not isinstance(seq_a, PackedSequence):
301
+ raise SequenceTypeError(
302
+ f"seq_a debe ser PackedSequence, se recibió {type(seq_a).__name__!r}."
303
+ )
304
+ if not isinstance(seq_b, PackedSequence):
305
+ raise SequenceTypeError(
306
+ f"seq_b debe ser PackedSequence, se recibió {type(seq_b).__name__!r}."
307
+ )
308
+ if seq_a.seq_type != seq_b.seq_type:
309
+ raise SequenceTypeError(
310
+ f"Los tipos no coinciden: "
311
+ f"{seq_a.seq_type.name} ≠ {seq_b.seq_type.name}."
312
+ )
313
+ if seq_a.n_symbols == 0 or seq_b.n_symbols == 0:
314
+ raise SequenceValueError("Las secuencias no pueden estar vacías.")
315
+
316
+ m, n = seq_a.n_symbols, seq_b.n_symbols
317
+ codes_a = seq_a.decode()
318
+ codes_b = seq_b.decode()
319
+
320
+ if C_AVAILABLE:
321
+ return cls._align_sw_c(codes_a, codes_b, m, n, seq_a.seq_type)
322
+
323
+ H = cls._fill_matrix_sw(codes_a, codes_b, m, n)
324
+ return cls._traceback_sw(H, codes_a, codes_b, m, n, seq_a.seq_type)
325
+
326
+ # ── Rutas C (motor nativo) ─────────────────────────────────────────────────
327
+
328
+ @classmethod
329
+ def _align_sw_c(
330
+ cls,
331
+ codes_a: np.ndarray,
332
+ codes_b: np.ndarray,
333
+ m: int,
334
+ n: int,
335
+ seq_type: SeqType,
336
+ ) -> AlignmentResult:
337
+ decode_bytes = (
338
+ _NUC_DECODE_BYTES if seq_type == SeqType.NUCLEOTIDE else _AA_DECODE_BYTES
339
+ )
340
+ aligned_a, aligned_b, score, n_matches, n_mismatches, n_gaps = _c_sw_align(
341
+ codes_a, codes_b, decode_bytes,
342
+ int(cls.MATCH), int(cls.MISMATCH), int(cls.GAP),
343
+ )
344
+ mutations = cls._detect_mutations(aligned_a, aligned_b)
345
+ aln_len = n_matches + n_mismatches + n_gaps
346
+ identity = n_matches / aln_len if aln_len else 0.0
347
+ return AlignmentResult(
348
+ score=score, identity=identity,
349
+ n_matches=n_matches, n_mismatches=n_mismatches, n_gaps=n_gaps,
350
+ mutations=mutations, aligned_a=aligned_a, aligned_b=aligned_b,
351
+ seq_type=seq_type, mode='local',
352
+ )
353
+
354
+ @classmethod
355
+ def _align_banded_c(
356
+ cls,
357
+ codes_a: np.ndarray,
358
+ codes_b: np.ndarray,
359
+ m: int,
360
+ n: int,
361
+ band: int,
362
+ mode: str,
363
+ seq_type: SeqType,
364
+ ) -> AlignmentResult:
365
+ decode_bytes = (
366
+ _NUC_DECODE_BYTES if seq_type == SeqType.NUCLEOTIDE else _AA_DECODE_BYTES
367
+ )
368
+ aligned_a, aligned_b, score, n_matches, n_mismatches, n_gaps = _c_nw_banded(
369
+ codes_a, codes_b, decode_bytes,
370
+ int(cls.MATCH), int(cls.MISMATCH), int(cls.GAP),
371
+ band, mode,
372
+ )
373
+ mutations = cls._detect_mutations(aligned_a, aligned_b)
374
+ aln_len = n_matches + n_mismatches + n_gaps
375
+ identity = n_matches / aln_len if aln_len else 0.0
376
+ return AlignmentResult(
377
+ score=score, identity=identity,
378
+ n_matches=n_matches, n_mismatches=n_mismatches, n_gaps=n_gaps,
379
+ mutations=mutations, aligned_a=aligned_a, aligned_b=aligned_b,
380
+ seq_type=seq_type, mode=mode,
381
+ )
382
+
383
+ @classmethod
384
+ def _align_c(
385
+ cls,
386
+ codes_a: np.ndarray,
387
+ codes_b: np.ndarray,
388
+ m: int,
389
+ n: int,
390
+ mode: str,
391
+ seq_type: SeqType,
392
+ ) -> AlignmentResult:
393
+ decode_bytes = (
394
+ _NUC_DECODE_BYTES if seq_type == SeqType.NUCLEOTIDE else _AA_DECODE_BYTES
395
+ )
396
+ aligned_a, aligned_b, score, n_matches, n_mismatches, n_gaps = _c_nw_align(
397
+ codes_a, codes_b, decode_bytes,
398
+ int(cls.MATCH), int(cls.MISMATCH), int(cls.GAP),
399
+ mode,
400
+ )
401
+ mutations = cls._detect_mutations(aligned_a, aligned_b)
402
+ aln_len = n_matches + n_mismatches + n_gaps
403
+ identity = n_matches / aln_len if aln_len else 0.0
404
+ return AlignmentResult(
405
+ score = score,
406
+ identity = identity,
407
+ n_matches = n_matches,
408
+ n_mismatches = n_mismatches,
409
+ n_gaps = n_gaps,
410
+ mutations = mutations,
411
+ aligned_a = aligned_a,
412
+ aligned_b = aligned_b,
413
+ seq_type = seq_type,
414
+ mode = mode,
415
+ )
416
+
417
+ @staticmethod
418
+ def _detect_mutations(aligned_a: str, aligned_b: str) -> list[Mutation]:
419
+ mutations: list[Mutation] = []
420
+ pos_a = pos_b = 0
421
+ for sa, sb in zip(aligned_a, aligned_b, strict=True):
422
+ if sa == '-':
423
+ mutations.append(Mutation('insertion', pos_a, pos_b, '-', sb))
424
+ pos_b += 1
425
+ elif sb == '-':
426
+ mutations.append(Mutation('deletion', pos_a, pos_b, sa, '-'))
427
+ pos_a += 1
428
+ else:
429
+ if sa != sb:
430
+ mutations.append(Mutation('substitution', pos_a, pos_b, sa, sb))
431
+ pos_a += 1
432
+ pos_b += 1
433
+ return mutations
434
+
435
+ # ── Scoring matrix (anti-diagonal wavefront) ──────────────────────────────
436
+
437
+ @classmethod
438
+ def _fill_matrix(
439
+ cls,
440
+ codes_a: np.ndarray,
441
+ codes_b: np.ndarray,
442
+ m: int,
443
+ n: int,
444
+ mode: str,
445
+ ) -> np.ndarray:
446
+ """
447
+ Fill the (m+1)×(n+1) NW matrix using the anti-diagonal wavefront.
448
+
449
+ Anti-diagonal d = i + j. For each d ≥ 2 the computable cells
450
+ satisfy 1 ≤ i ≤ m and 1 ≤ j = d−i ≤ n, i.e.:
451
+ i ∈ [max(1, d−n) … min(m, d−1)]
452
+
453
+ All cells in one anti-diagonal are independent and are computed
454
+ in a single vectorised NumPy step.
455
+
456
+ Returns
457
+ -------
458
+ np.ndarray, dtype int32, shape (m+1, n+1)
459
+ """
460
+ H = np.zeros((m + 1, n + 1), dtype=np.int32)
461
+
462
+ if mode == 'global':
463
+ H[0, :] = np.arange(n + 1, dtype=np.int32) * cls.GAP
464
+ H[:, 0] = np.arange(m + 1, dtype=np.int32) * cls.GAP
465
+ # semi-global: borders stay at 0 (free terminal gaps on the query)
466
+
467
+ # Pre-allocate index buffers once outside the loop.
468
+ # i_full[k] = k+1 → i_full[i_lo-1 : i_hi] is a zero-copy slice [i_lo…i_hi].
469
+ # j_buf receives d − i_arr in-place, eliminating one allocation per diagonal.
470
+ i_full = np.arange(1, max(m, n) + 1, dtype=np.int32)
471
+ j_buf = np.empty(min(m, n) + 1, dtype=np.int32)
472
+
473
+ for d in range(2, m + n + 1):
474
+ i_lo = max(1, d - n)
475
+ i_hi = min(m, d - 1) # j = d−i ≥ 1 ⟹ i ≤ d−1
476
+ if i_lo > i_hi:
477
+ continue
478
+
479
+ w = i_hi - i_lo + 1
480
+ i_arr = i_full[i_lo - 1: i_hi] # zero-copy slice — no allocation
481
+ np.subtract(d, i_arr, out=j_buf[:w]) # in-place: avoids j = d - i_arr copy
482
+ j_arr = j_buf[:w]
483
+
484
+ # ① Match/mismatch score per cell
485
+ match_mask = codes_a[i_arr - 1] == codes_b[j_arr - 1]
486
+ step = np.where(match_mask, cls.MATCH, cls.MISMATCH).astype(np.int32)
487
+
488
+ # ② Three candidate moves
489
+ diag = H[i_arr - 1, j_arr - 1] + step # ↖ match/mismatch
490
+ up = H[i_arr - 1, j_arr ] + cls.GAP # ↑ gap in seq_b
491
+ left = H[i_arr, j_arr - 1] + cls.GAP # ← gap in seq_a
492
+
493
+ # ③ Cell = maximum of three
494
+ H[i_arr, j_arr] = np.maximum(np.maximum(diag, up), left)
495
+
496
+ return H
497
+
498
+ @classmethod
499
+ def _fill_matrix_banded(
500
+ cls,
501
+ codes_a: np.ndarray,
502
+ codes_b: np.ndarray,
503
+ m: int,
504
+ n: int,
505
+ band: int,
506
+ mode: str,
507
+ ) -> np.ndarray:
508
+ """
509
+ Fill the NW matrix restricted to the band |i-j| ≤ band.
510
+
511
+ Out-of-band cells are initialised to NEG_INF so they can never
512
+ be chosen as optimal predecessors. The anti-diagonal wavefront
513
+ is clipped to the band intersection on each diagonal.
514
+ """
515
+ NEG = np.int32(-10 ** 9)
516
+ H = np.full((m + 1, n + 1), NEG, dtype=np.int32)
517
+
518
+ H[0, 0] = np.int32(0)
519
+ for j in range(1, min(n, band) + 1):
520
+ H[0, j] = np.int32(0) if mode == 'semi-global' else np.int32(j) * cls.GAP
521
+ for i in range(1, min(m, band) + 1):
522
+ H[i, 0] = np.int32(i) * cls.GAP
523
+
524
+ i_full = np.arange(1, max(m, n) + 1, dtype=np.int32)
525
+ j_buf = np.empty(min(m, n) + 1, dtype=np.int32)
526
+
527
+ for d in range(2, m + n + 1):
528
+ i_lo = max(1, d - n, (d - band + 1) // 2)
529
+ i_hi = min(m, d - 1, (d + band) // 2)
530
+ if i_lo > i_hi:
531
+ continue
532
+
533
+ w = i_hi - i_lo + 1
534
+ i_arr = i_full[i_lo - 1: i_hi]
535
+ np.subtract(d, i_arr, out=j_buf[:w])
536
+ j_arr = j_buf[:w]
537
+
538
+ dv = H[i_arr - 1, j_arr - 1]
539
+ uv = H[i_arr - 1, j_arr]
540
+ lv = H[i_arr, j_arr - 1]
541
+
542
+ match_mask = codes_a[i_arr - 1] == codes_b[j_arr - 1]
543
+ step = np.where(match_mask, cls.MATCH, cls.MISMATCH).astype(np.int32)
544
+
545
+ d_score = np.where(dv != NEG, dv + step, NEG)
546
+ u_score = np.where(uv != NEG, uv + cls.GAP, NEG)
547
+ l_score = np.where(lv != NEG, lv + cls.GAP, NEG)
548
+
549
+ H[i_arr, j_arr] = np.maximum(np.maximum(d_score, u_score), l_score)
550
+
551
+ return H
552
+
553
+ @classmethod
554
+ def _fill_matrix_sw(
555
+ cls,
556
+ codes_a: np.ndarray,
557
+ codes_b: np.ndarray,
558
+ m: int,
559
+ n: int,
560
+ ) -> np.ndarray:
561
+ """
562
+ Fill the Smith-Waterman scoring matrix.
563
+
564
+ Identical to NW except cells are floored at 0.
565
+ Uses the same anti-diagonal wavefront strategy (O(m+n) iterations).
566
+ """
567
+ H = np.zeros((m + 1, n + 1), dtype=np.int32)
568
+ # Borders stay 0 (SW boundary condition)
569
+
570
+ i_full = np.arange(1, max(m, n) + 1, dtype=np.int32)
571
+ j_buf = np.empty(min(m, n) + 1, dtype=np.int32)
572
+
573
+ for d in range(2, m + n + 1):
574
+ i_lo = max(1, d - n)
575
+ i_hi = min(m, d - 1)
576
+ if i_lo > i_hi:
577
+ continue
578
+
579
+ w = i_hi - i_lo + 1
580
+ i_arr = i_full[i_lo - 1: i_hi]
581
+ np.subtract(d, i_arr, out=j_buf[:w])
582
+ j_arr = j_buf[:w]
583
+
584
+ match_mask = codes_a[i_arr - 1] == codes_b[j_arr - 1]
585
+ step = np.where(match_mask, cls.MATCH, cls.MISMATCH).astype(np.int32)
586
+
587
+ diag = H[i_arr - 1, j_arr - 1] + step
588
+ up = H[i_arr - 1, j_arr ] + cls.GAP
589
+ left = H[i_arr, j_arr - 1] + cls.GAP
590
+
591
+ # SW: floor at 0
592
+ H[i_arr, j_arr] = np.maximum(
593
+ np.int32(0), np.maximum(np.maximum(diag, up), left)
594
+ )
595
+
596
+ return H
597
+
598
+ @classmethod
599
+ def _traceback_sw(
600
+ cls,
601
+ H: np.ndarray,
602
+ codes_a: np.ndarray,
603
+ codes_b: np.ndarray,
604
+ m: int,
605
+ n: int,
606
+ seq_type: SeqType,
607
+ ) -> AlignmentResult:
608
+ """Traceback for Smith-Waterman: start at max(H), stop at 0."""
609
+ decode = _NUC_DECODE if seq_type == SeqType.NUCLEOTIDE else _AA_DECODE
610
+
611
+ max_idx = np.argmax(H)
612
+ score = int(H.flat[max_idx])
613
+ start_i, start_j = divmod(int(max_idx), n + 1)
614
+
615
+ i, j = start_i, start_j
616
+ aln_a: list[str] = []
617
+ aln_b: list[str] = []
618
+ mutations: list[Mutation] = []
619
+ n_matches = n_mismatches = n_gaps = 0
620
+
621
+ while i > 0 and j > 0 and H[i, j] > 0:
622
+ step = cls.MATCH if codes_a[i-1] == codes_b[j-1] else cls.MISMATCH
623
+
624
+ if H[i, j] == H[i-1, j-1] + step:
625
+ sa = decode.get(int(codes_a[i-1]), '?')
626
+ sb = decode.get(int(codes_b[j-1]), '?')
627
+ aln_a.append(sa); aln_b.append(sb)
628
+ if codes_a[i-1] == codes_b[j-1]:
629
+ n_matches += 1
630
+ else:
631
+ n_mismatches += 1
632
+ mutations.append(Mutation('substitution', i - 1, j - 1, sa, sb))
633
+ i -= 1; j -= 1
634
+
635
+ elif H[i, j] == H[i-1, j] + cls.GAP:
636
+ sa = decode.get(int(codes_a[i-1]), '?')
637
+ aln_a.append(sa); aln_b.append('-')
638
+ n_gaps += 1
639
+ mutations.append(Mutation('deletion', i - 1, j, sa, '-'))
640
+ i -= 1
641
+
642
+ else:
643
+ sb = decode.get(int(codes_b[j-1]), '?')
644
+ aln_a.append('-'); aln_b.append(sb)
645
+ n_gaps += 1
646
+ mutations.append(Mutation('insertion', i, j - 1, '-', sb))
647
+ j -= 1
648
+
649
+ aln_a.reverse(); aln_b.reverse(); mutations.reverse()
650
+ aln_len = n_matches + n_mismatches + n_gaps
651
+ identity = n_matches / aln_len if aln_len else 0.0
652
+
653
+ return AlignmentResult(
654
+ score=score, identity=identity,
655
+ n_matches=n_matches, n_mismatches=n_mismatches, n_gaps=n_gaps,
656
+ mutations=mutations,
657
+ aligned_a=''.join(aln_a), aligned_b=''.join(aln_b),
658
+ seq_type=seq_type, mode='local',
659
+ )
660
+
661
+ # ── Traceback ─────────────────────────────────────────────────────────────
662
+
663
+ @classmethod
664
+ def _traceback(
665
+ cls,
666
+ H: np.ndarray,
667
+ codes_a: np.ndarray,
668
+ codes_b: np.ndarray,
669
+ m: int,
670
+ n: int,
671
+ mode: str,
672
+ seq_type: SeqType,
673
+ ) -> AlignmentResult:
674
+ """
675
+ Reconstruct the optimal alignment by walking back through H.
676
+
677
+ O(m+n) sequential loop — the step dependency makes vectorisation
678
+ impossible here. Each iteration is O(1) scalar work.
679
+ """
680
+ decode = _NUC_DECODE if seq_type == SeqType.NUCLEOTIDE else _AA_DECODE
681
+
682
+ # ── Starting cell ──────────────────────────────────────────────────────
683
+ if mode == 'global':
684
+ i, j = m, n
685
+ else:
686
+ # Semi-global: pick the best score in the last row or last column.
687
+ j_best_row = int(np.argmax(H[m, :]))
688
+ i_best_col = int(np.argmax(H[:, n]))
689
+ if H[m, j_best_row] >= H[i_best_col, n]:
690
+ i, j = m, j_best_row
691
+ else:
692
+ i, j = i_best_col, n
693
+
694
+ score = int(H[i, j])
695
+
696
+ aln_a: list[str] = []
697
+ aln_b: list[str] = []
698
+ mutations: list[Mutation] = []
699
+ n_matches = n_mismatches = n_gaps = 0
700
+
701
+ # ── Traceback loop ─────────────────────────────────────────────────────
702
+ while i > 0 or j > 0:
703
+
704
+ if i > 0 and j > 0:
705
+ step = cls.MATCH if codes_a[i-1] == codes_b[j-1] else cls.MISMATCH
706
+
707
+ if H[i, j] == H[i-1, j-1] + step:
708
+ # Diagonal — match or mismatch
709
+ sa = decode.get(int(codes_a[i-1]), '?')
710
+ sb = decode.get(int(codes_b[j-1]), '?')
711
+ aln_a.append(sa)
712
+ aln_b.append(sb)
713
+ if codes_a[i-1] == codes_b[j-1]:
714
+ n_matches += 1
715
+ else:
716
+ n_mismatches += 1
717
+ mutations.append(
718
+ Mutation('substitution', i - 1, j - 1, sa, sb)
719
+ )
720
+ i -= 1; j -= 1
721
+
722
+ elif H[i, j] == H[i-1, j] + cls.GAP:
723
+ # Up — gap in seq_b (deletion in query vs reference)
724
+ sa = decode.get(int(codes_a[i-1]), '?')
725
+ aln_a.append(sa)
726
+ aln_b.append('-')
727
+ n_gaps += 1
728
+ mutations.append(Mutation('deletion', i - 1, j, sa, '-'))
729
+ i -= 1
730
+
731
+ else:
732
+ # Left — gap in seq_a (insertion in query vs reference)
733
+ sb = decode.get(int(codes_b[j-1]), '?')
734
+ aln_a.append('-')
735
+ aln_b.append(sb)
736
+ n_gaps += 1
737
+ mutations.append(Mutation('insertion', i, j - 1, '-', sb))
738
+ j -= 1
739
+
740
+ elif i > 0:
741
+ # Terminal: remaining seq_a consumed as gap in seq_b
742
+ sa = decode.get(int(codes_a[i-1]), '?')
743
+ aln_a.append(sa)
744
+ aln_b.append('-')
745
+ if mode == 'global':
746
+ n_gaps += 1
747
+ mutations.append(Mutation('deletion', i - 1, 0, sa, '-'))
748
+ i -= 1
749
+
750
+ else:
751
+ # Terminal: remaining seq_b consumed as gap in seq_a
752
+ sb = decode.get(int(codes_b[j-1]), '?')
753
+ aln_a.append('-')
754
+ aln_b.append(sb)
755
+ if mode == 'global':
756
+ n_gaps += 1
757
+ mutations.append(Mutation('insertion', 0, j - 1, '-', sb))
758
+ j -= 1
759
+
760
+ # Traceback builds alignment end→start; reverse to restore order.
761
+ aln_a.reverse()
762
+ aln_b.reverse()
763
+ mutations.reverse()
764
+
765
+ aln_len = n_matches + n_mismatches + n_gaps
766
+ identity = n_matches / aln_len if aln_len else 0.0
767
+
768
+ return AlignmentResult(
769
+ score = score,
770
+ identity = identity,
771
+ n_matches = n_matches,
772
+ n_mismatches = n_mismatches,
773
+ n_gaps = n_gaps,
774
+ mutations = mutations,
775
+ aligned_a = ''.join(aln_a),
776
+ aligned_b = ''.join(aln_b),
777
+ seq_type = seq_type,
778
+ mode = mode,
779
+ )
780
+
781
+
782
+ # ══════════════════════════════════════════════════════════════════════════════
783
+ # §3 UTILITIES
784
+ # ══════════════════════════════════════════════════════════════════════════════
785
+
786
+ def format_alignment(result: AlignmentResult, width: int = 60) -> str:
787
+ """
788
+ Return a human-readable block alignment string.
789
+
790
+ Symbol legend: '|' match · 'X' mismatch · ' ' gap.
791
+ Vectorised: builds the midline with NumPy, no Python character loop.
792
+ """
793
+ if width <= 0:
794
+ raise AlignmentError(f"width debe ser > 0, se recibió {width}.")
795
+ a, b = result.aligned_a, result.aligned_b
796
+ if len(a) != len(b):
797
+ raise AlignmentError(
798
+ f"aligned_a y aligned_b tienen longitudes distintas: {len(a)} ≠ {len(b)}."
799
+ )
800
+ a_arr = np.frombuffer(a.encode(), dtype=np.uint8)
801
+ b_arr = np.frombuffer(b.encode(), dtype=np.uint8)
802
+ gap = np.uint8(ord('-'))
803
+ mid = np.full(len(a_arr), ord(' '), dtype=np.uint8)
804
+ match = (a_arr == b_arr) & (a_arr != gap)
805
+ mism = (a_arr != b_arr) & (a_arr != gap) & (b_arr != gap)
806
+ mid[match] = ord('|')
807
+ mid[mism] = ord('X')
808
+ midline = mid.tobytes().decode('ascii')
809
+ lines: list[str] = []
810
+ for s in range(0, len(a), width):
811
+ e = s + width
812
+ lines += [
813
+ f" A: {a[s:e]}",
814
+ f" {midline[s:e]}",
815
+ f" B: {b[s:e]}",
816
+ "",
817
+ ]
818
+ return '\n'.join(lines)
819
+
820
+
821
+ # ══════════════════════════════════════════════════════════════════════════════
822
+ # §4 DEMO / SELF-TEST (python aligner.py)
823
+ # ══════════════════════════════════════════════════════════════════════════════
824
+
825
+ if __name__ == "__main__":
826
+ import sys
827
+ import time
828
+ sys.stdout.reconfigure(encoding="utf-8")
829
+ sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
830
+ from bioforge import SmartImporter
831
+
832
+ W = 65
833
+ print("═" * W)
834
+ print(" aligner.py — Needleman-Wunsch anti-diagonal wavefront demo")
835
+ print("═" * W)
836
+
837
+ # ── Test 1: secuencias idénticas (sanity check) ────────────────────────
838
+ print("\n ── Test 1: secuencias idénticas " + "─" * 32)
839
+ _SEQ = "ATGGTGCACCTGACTCCTGAGGAGAAGTCTGCCGTTACTGCCCTGTGGGG"
840
+ _FASTA = f">a\n{_SEQ}\n>b\n{_SEQ}\n"
841
+ _seqs = SmartImporter.from_string(_FASTA, force_type=SeqType.NUCLEOTIDE)
842
+ r1 = SequenceAligner.align(_seqs[0], _seqs[1])
843
+ assert r1.n_mismatches == 0 and r1.n_gaps == 0, "❌ deberían ser idénticas"
844
+ assert r1.identity == 1.0
845
+ assert r1.score == len(_SEQ) * int(SequenceAligner.MATCH)
846
+ print(f" Score={r1.score} Identity={r1.identity:.0%} "
847
+ f"Mutaciones={len(r1.mutations)} ✅")
848
+
849
+ # ── Test 2: HBB normal vs sickle cell (única sustitución A→T, pos 19) ──
850
+ print("\n ── Test 2: HBB normal vs sickle cell (A19T) " + "─" * 20)
851
+ _NORMAL = ">HBB_normal\nATGGTGCACCTGACTCCTGAGGAGAAGTCT\n"
852
+ _SICKLE = ">HBB_sickle\nATGGTGCACCTGACTCCTGTGGAGAAGTCT\n"
853
+ _n = SmartImporter.from_string(_NORMAL, force_type=SeqType.NUCLEOTIDE)[0]
854
+ _s = SmartImporter.from_string(_SICKLE, force_type=SeqType.NUCLEOTIDE)[0]
855
+ r2 = SequenceAligner.align(_n, _s)
856
+ print(f" Score={r2.score} Identity={r2.identity:.1%} "
857
+ f"Mismatches={r2.n_mismatches} Gaps={r2.n_gaps}")
858
+ print(format_alignment(r2))
859
+ assert len(r2.mutations) == 1
860
+ assert r2.mutations[0].kind == 'substitution'
861
+ assert r2.mutations[0].pos_a == 19
862
+ assert r2.mutations[0].sym_a == 'A' and r2.mutations[0].sym_b == 'T'
863
+ print(f" Mutación detectada: {r2.mutations[0]} ✅")
864
+
865
+ # ── Test 3: inserción de 3 bases (codón extra) ─────────────────────────
866
+ print("\n ── Test 3: inserción de 3 bases " + "─" * 33)
867
+ _REF = ">ref\nATGGTGCACCTGACTGAA\n" # 18 nt
868
+ _INS = ">ins\nATGGTGCACCTGACTCCCGAA\n" # 21 nt (CCC en pos 15)
869
+ _r = SmartImporter.from_string(_REF, force_type=SeqType.NUCLEOTIDE)[0]
870
+ _i = SmartImporter.from_string(_INS, force_type=SeqType.NUCLEOTIDE)[0]
871
+ r3 = SequenceAligner.align(_r, _i)
872
+ print(f" Score={r3.score} Identity={r3.identity:.1%} "
873
+ f"Mismatches={r3.n_mismatches} Gaps={r3.n_gaps}")
874
+ print(format_alignment(r3))
875
+ _ins_muts = [m for m in r3.mutations if m.kind == 'insertion']
876
+ print(f" Inserciones detectadas: {len(_ins_muts)} base(s) "
877
+ f"{'✅' if _ins_muts else '⚠ (ninguna)'}")
878
+
879
+ # ── Test 4: alineamiento de proteínas (HBB α vs β) ────────────────────
880
+ print("\n ── Test 4: proteínas HBB-alpha vs HBB-beta " + "─" * 21)
881
+ _HBB = ">HBB\nMVHLTPEEKSAVTALWGKVNVDEVGGEALGRLLVVYPWTQRFFESFGDLST\n"
882
+ _HBA = ">HBA\nMVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLS\n"
883
+ _hbb = SmartImporter.from_string(_HBB, force_type=SeqType.PROTEIN)[0]
884
+ _hba = SmartImporter.from_string(_HBA, force_type=SeqType.PROTEIN)[0]
885
+ r4 = SequenceAligner.align(_hbb, _hba)
886
+ print(f" Score={r4.score} Identity={r4.identity:.1%} "
887
+ f"Matches={r4.n_matches} Mismatches={r4.n_mismatches} "
888
+ f"Gaps={r4.n_gaps}")
889
+ print(format_alignment(r4))
890
+ print(f" Mutaciones totales: {len(r4.mutations)}")
891
+ for _m in r4.mutations[:5]:
892
+ print(f" {_m}")
893
+ if len(r4.mutations) > 5:
894
+ print(f" … y {len(r4.mutations) - 5} más")
895
+
896
+ # ── Test 5: rutas de error ─────────────────────────────────────────────
897
+ print("\n ── Test 5: rutas de error " + "─" * 39)
898
+ _prot = PackedSequence(
899
+ header="p", seq_type=SeqType.PROTEIN, n_symbols=4,
900
+ data=BitPacker.pack(np.array([4, 5, 6, 7], dtype=np.uint8)),
901
+ )
902
+ _nuc = SmartImporter.from_string(">n\nACGT\n",
903
+ force_type=SeqType.NUCLEOTIDE)[0]
904
+ try:
905
+ SequenceAligner.align(_prot, _nuc)
906
+ print(" TypeError no lanzado ❌")
907
+ except TypeError as exc:
908
+ print(f" TypeError ✅ → {exc}")
909
+
910
+ _empty = PackedSequence(
911
+ header="e", seq_type=SeqType.NUCLEOTIDE, n_symbols=1,
912
+ data=BitPacker.pack(np.array([0], dtype=np.uint8)),
913
+ )
914
+ _empty2 = PackedSequence(
915
+ header="e2", seq_type=SeqType.NUCLEOTIDE, n_symbols=1,
916
+ data=BitPacker.pack(np.array([0], dtype=np.uint8)),
917
+ )
918
+ r_tiny = SequenceAligner.align(_empty, _empty2)
919
+ assert r_tiny.score == int(SequenceAligner.MATCH)
920
+ print(f" Alineamiento 1×1 (A vs A): score={r_tiny.score} ✅")
921
+
922
+ # ── Test 6: benchmark — 1 000 × 1 000 nt con 1 % mutaciones ───────────
923
+ print("\n ── Test 6: benchmark 1 000 × 1 000 nt (1 % mutaciones) " + "─" * 8)
924
+ _rng = np.random.default_rng(42)
925
+ _bases = np.array([0, 1, 2, 3], dtype=np.uint8)
926
+ _ca = _rng.choice(_bases, size=1000)
927
+ _cb = _ca.copy()
928
+ _mutpos = _rng.choice(1000, size=10, replace=False)
929
+ for _p in _mutpos:
930
+ _cb[_p] = (_cb[_p] + 1) % 4 # cyclic 1-step substitution
931
+
932
+ _ps_a = PackedSequence(
933
+ header="bench_a", seq_type=SeqType.NUCLEOTIDE,
934
+ n_symbols=1000, data=BitPacker.pack(_ca),
935
+ )
936
+ _ps_b = PackedSequence(
937
+ header="bench_b", seq_type=SeqType.NUCLEOTIDE,
938
+ n_symbols=1000, data=BitPacker.pack(_cb),
939
+ )
940
+
941
+ _t0 = time.perf_counter()
942
+ _rb = SequenceAligner.align(_ps_a, _ps_b)
943
+ _t1 = time.perf_counter()
944
+
945
+ _elapsed_ms = (_t1 - _t0) * 1e3
946
+ print(f" Tiempo : {_elapsed_ms:.1f} ms")
947
+ print(f" Score : {_rb.score}")
948
+ print(f" Identity : {_rb.identity:.2%}")
949
+ print(f" Mutaciones : {len(_rb.mutations)} (esperadas ≈ 10)")
950
+ assert abs(len(_rb.mutations) - 10) <= 2, "Mutaciones fuera de rango esperado"
951
+ print(" ✅ Benchmark superado")
952
+
953
+ print(f"\n{'═' * W}\n")