Skip to main content

qualia_core_db/domains/biological/
bioinformatics.rs

1//! SIMD-Accelerated Bioinformatics Engine.
2//!
3//! Production-quality sequence alignment with hardware dispatch:
4//! - Smith-Waterman local alignment (affine gap penalties)
5//! - Needleman-Wunsch global alignment
6//! - BLOSUM62 / nucleotide substitution matrices
7//! - K-mer frequency analysis + MinHash sketching
8//! - FASTA record validation
9//! - Tanimoto metabolite fingerprint similarity
10//!
11//! `qualia:alignNucleotideSequence` → `align_nucleotide()`
12//! `qualia:alignProteinSequence`    → `align_protein()`
13//! `qualia:computeKmerFrequency`    → `kmer_frequencies()`
14//! `qualia:validateFastaRecord`     → `validate_fasta_record()`
15//! `qualia:computeMetaboliteSimilarity` → `tanimoto_similarity()`
16
17#![allow(unused_imports)]
18#![allow(unused_unsafe)]
19
20// ─── Core types ───────────────────────────────────────────────────────────────
21
22/// Lightweight backward-compatible score wrapper.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct AlignmentScore {
25    pub score: i32,
26}
27
28/// Full alignment result with traceback and statistics.
29#[derive(Debug, Clone)]
30pub struct AlignmentResult {
31    /// Smith-Waterman or Needleman-Wunsch score.
32    pub score: i32,
33    /// Aligned query sequence (gaps encoded as b'-').
34    pub aligned_query: Vec<u8>,
35    /// Aligned target sequence (gaps encoded as b'-').
36    pub aligned_target: Vec<u8>,
37    /// Percent identity over the aligned region.
38    pub identity_pct: f32,
39    pub num_matches: usize,
40    pub num_gaps: usize,
41}
42
43/// Affine gap penalty model (BLAST defaults: open=-11, extend=-1).
44#[derive(Debug, Clone, Copy)]
45pub struct GapPenalty {
46    pub open: i32,
47    pub extend: i32,
48}
49
50impl Default for GapPenalty {
51    fn default() -> Self {
52        Self {
53            open: -11,
54            extend: -1,
55        }
56    }
57}
58
59/// Nucleotide substitution scores.
60#[derive(Debug, Clone, Copy)]
61pub struct NucleotideMatrix {
62    pub match_score: i32,
63    pub mismatch_score: i32,
64}
65
66impl Default for NucleotideMatrix {
67    fn default() -> Self {
68        Self {
69            match_score: 2,
70            mismatch_score: -3,
71        }
72    }
73}
74
75// ─── BLOSUM62 ─────────────────────────────────────────────────────────────────
76// Canonical NCBI BLOSUM62.  Row/column order: A C D E F G H I K L M N P Q R S T V W Y
77
78static BLOSUM62_ORDER: &[u8] = b"ACDEFGHIKLMNPQRSTVWY";
79
80#[rustfmt::skip]
81static BLOSUM62: [[i8; 20]; 20] = [
82//   A   C   D   E   F   G   H   I   K   L   M   N   P   Q   R   S   T   V   W   Y
83    [ 4, -1, -2, -1, -2,  0, -2, -1, -1, -1, -1, -2, -1, -1, -1,  1,  0,  0, -3, -2], // A
84    [-1,  9, -3, -4, -2, -3, -3, -1, -3, -1, -1, -3, -3, -3, -3, -1, -1, -1, -2, -2], // C
85    [-2, -3,  6,  2, -3, -1, -1, -3, -1, -4, -3,  1, -1,  0, -2,  0, -1, -3, -4, -3], // D
86    [-1, -4,  2,  5, -3, -2,  0, -3,  1, -3, -2,  0, -1,  2,  0,  0, -1, -2, -3, -2], // E
87    [-2, -2, -3, -3,  6, -3, -1,  0, -3,  0,  0, -3, -4, -3, -3, -2, -2, -1,  1,  3], // F
88    [ 0, -3, -1, -2, -3,  6, -2, -4, -2, -4, -3,  0, -2, -2, -2,  0, -2, -3, -2, -3], // G
89    [-2, -3, -1,  0, -1, -2,  8, -3, -1, -3, -2,  1, -2,  0,  0, -1, -2, -3, -2,  2], // H
90    [-1, -1, -3, -3,  0, -4, -3,  4, -3,  2,  1, -3, -3, -3, -3, -2, -1,  3, -3, -1], // I
91    [-1, -3, -1,  1, -3, -2, -1, -3,  5, -2, -1,  0, -1,  1,  2,  0, -1, -2, -3, -2], // K
92    [-1, -1, -4, -3,  0, -4, -3,  2, -2,  4,  2, -3, -3, -2, -2, -2, -1,  1, -2, -1], // L
93    [-1, -1, -3, -2,  0, -3, -2,  1, -1,  2,  5, -2, -2,  0, -1, -1, -1,  1, -1, -1], // M
94    [-2, -3,  1,  0, -3,  0,  1, -3,  0, -3, -2,  6, -2,  0,  0,  1,  0, -3, -4, -2], // N
95    [-1, -3, -1, -1, -4, -2, -2, -3, -1, -3, -2, -2,  7, -1, -2, -1, -1, -2, -4, -3], // P
96    [-1, -3,  0,  2, -3, -2,  0, -3,  1, -2,  0,  0, -1,  5,  1,  0, -1, -2, -2, -1], // Q
97    [-1, -3, -2,  0, -3, -2,  0, -3,  2, -2, -1,  0, -2,  1,  5, -1, -1, -3, -3, -2], // R
98    [ 1, -1,  0,  0, -2,  0, -1, -2,  0, -2, -1,  1, -1,  0, -1,  4,  1, -2, -3, -2], // S
99    [ 0, -1, -1, -1, -2, -2, -2, -1, -1, -1, -1,  0, -1, -1, -1,  1,  5,  0, -2, -2], // T
100    [ 0, -1, -3, -2, -1, -3, -3,  3, -2,  1,  1, -3, -2, -2, -3, -2,  0,  4, -3, -1], // V
101    [-3, -2, -4, -3,  1, -2, -2, -3, -3, -2, -1, -4, -4, -2, -3, -3, -2, -3, 11,  2], // W
102    [-2, -2, -3, -2,  3, -3,  2, -1, -2, -1, -1, -2, -3, -1, -2, -2, -2, -1,  2,  7], // Y
103];
104
105#[inline]
106fn blosum62_idx(aa: u8) -> Option<usize> {
107    BLOSUM62_ORDER
108        .iter()
109        .position(|&c| c == aa.to_ascii_uppercase())
110}
111
112/// BLOSUM62 score for two amino acid bytes.  Unknown residues → -4.
113#[inline]
114pub fn blosum62_score(a: u8, b: u8) -> i32 {
115    match (blosum62_idx(a), blosum62_idx(b)) {
116        (Some(i), Some(j)) => BLOSUM62[i][j] as i32,
117        _ => -4,
118    }
119}
120
121// ─── Smith-Waterman (affine gap) ──────────────────────────────────────────────
122
123/// Maximum sequence length accepted to prevent OOM on untrusted input.
124const MAX_SEQ_LEN: usize = 50_000;
125
126/// Smith-Waterman local alignment with affine gap penalties.
127/// Allocates O(m×n) DP tables — caller should respect MAX_SEQ_LEN.
128pub fn smith_waterman(
129    query: &[u8],
130    target: &[u8],
131    gap: GapPenalty,
132    score_fn: impl Fn(u8, u8) -> i32,
133) -> AlignmentResult {
134    let m = query.len();
135    let n = target.len();
136    if m == 0 || n == 0 || m > MAX_SEQ_LEN || n > MAX_SEQ_LEN {
137        return empty_result();
138    }
139
140    let neg_inf = i32::MIN / 2;
141
142    // H: best score ending here; E: gap in target (horizontal); F: gap in query (vertical)
143    let mut h = vec![vec![0i32; n + 1]; m + 1];
144    let mut e = vec![vec![neg_inf; n + 1]; m + 1];
145    let mut f = vec![vec![neg_inf; n + 1]; m + 1];
146
147    // Traceback: 0=stop, 1=diag, 2=left(E), 3=up(F)
148    let mut tb = vec![vec![0u8; n + 1]; m + 1];
149
150    let mut best_score = 0i32;
151    let mut best_i = 0usize;
152    let mut best_j = 0usize;
153
154    for i in 1..=m {
155        for j in 1..=n {
156            e[i][j] = (h[i][j - 1].saturating_add(gap.open + gap.extend))
157                .max(e[i][j - 1].saturating_add(gap.extend));
158            f[i][j] = (h[i - 1][j].saturating_add(gap.open + gap.extend))
159                .max(f[i - 1][j].saturating_add(gap.extend));
160
161            let diag = h[i - 1][j - 1].saturating_add(score_fn(query[i - 1], target[j - 1]));
162            let cell = diag.max(e[i][j]).max(f[i][j]).max(0);
163            h[i][j] = cell;
164
165            tb[i][j] = if cell == 0 {
166                0
167            } else if cell == diag {
168                1
169            } else if cell == e[i][j] {
170                2
171            } else {
172                3
173            };
174
175            if cell > best_score {
176                best_score = cell;
177                best_i = i;
178                best_j = j;
179            }
180        }
181    }
182
183    traceback_local(&h, &tb, query, target, best_i, best_j, best_score)
184}
185
186fn traceback_local(
187    h: &[Vec<i32>],
188    tb: &[Vec<u8>],
189    query: &[u8],
190    target: &[u8],
191    mut i: usize,
192    mut j: usize,
193    score: i32,
194) -> AlignmentResult {
195    let mut aq = Vec::new();
196    let mut at = Vec::new();
197    let mut matches = 0usize;
198    let mut gaps = 0usize;
199
200    while i > 0 && j > 0 && h[i][j] > 0 {
201        match tb[i][j] {
202            1 => {
203                aq.push(query[i - 1]);
204                at.push(target[j - 1]);
205                if query[i - 1].eq_ignore_ascii_case(&target[j - 1]) {
206                    matches += 1;
207                }
208                i -= 1;
209                j -= 1;
210            }
211            2 => {
212                aq.push(b'-');
213                at.push(target[j - 1]);
214                gaps += 1;
215                j -= 1;
216            }
217            3 => {
218                aq.push(query[i - 1]);
219                at.push(b'-');
220                gaps += 1;
221                i -= 1;
222            }
223            _ => break,
224        }
225    }
226    aq.reverse();
227    at.reverse();
228    let aln_len = aq.len();
229    AlignmentResult {
230        score,
231        identity_pct: if aln_len > 0 {
232            100.0 * matches as f32 / aln_len as f32
233        } else {
234            0.0
235        },
236        aligned_query: aq,
237        aligned_target: at,
238        num_matches: matches,
239        num_gaps: gaps,
240    }
241}
242
243fn empty_result() -> AlignmentResult {
244    AlignmentResult {
245        score: 0,
246        aligned_query: vec![],
247        aligned_target: vec![],
248        identity_pct: 0.0,
249        num_matches: 0,
250        num_gaps: 0,
251    }
252}
253
254// ─── Needleman-Wunsch (linear gap) ───────────────────────────────────────────
255
256/// Needleman-Wunsch global alignment with linear gap penalty.
257pub fn needleman_wunsch(
258    query: &[u8],
259    target: &[u8],
260    gap: GapPenalty,
261    score_fn: impl Fn(u8, u8) -> i32,
262) -> AlignmentResult {
263    let m = query.len();
264    let n = target.len();
265    if m == 0 || n == 0 || m > MAX_SEQ_LEN || n > MAX_SEQ_LEN {
266        return empty_result();
267    }
268    let g = gap.open + gap.extend;
269    let mut dp = vec![vec![0i32; n + 1]; m + 1];
270    for i in 0..=m {
271        dp[i][0] = i as i32 * g;
272    }
273    for j in 0..=n {
274        dp[0][j] = j as i32 * g;
275    }
276
277    for i in 1..=m {
278        for j in 1..=n {
279            let sub = dp[i - 1][j - 1] + score_fn(query[i - 1], target[j - 1]);
280            let del = dp[i - 1][j] + g;
281            let ins = dp[i][j - 1] + g;
282            dp[i][j] = sub.max(del).max(ins);
283        }
284    }
285
286    let mut aq = Vec::new();
287    let mut at = Vec::new();
288    let mut matches = 0usize;
289    let mut gaps = 0usize;
290    let (mut i, mut j) = (m, n);
291
292    while i > 0 || j > 0 {
293        if i > 0 && j > 0 && dp[i][j] == dp[i - 1][j - 1] + score_fn(query[i - 1], target[j - 1]) {
294            aq.push(query[i - 1]);
295            at.push(target[j - 1]);
296            if query[i - 1].eq_ignore_ascii_case(&target[j - 1]) {
297                matches += 1;
298            }
299            i -= 1;
300            j -= 1;
301        } else if i > 0 && (j == 0 || dp[i][j] == dp[i - 1][j] + g) {
302            aq.push(query[i - 1]);
303            at.push(b'-');
304            gaps += 1;
305            i -= 1;
306        } else {
307            aq.push(b'-');
308            at.push(target[j - 1]);
309            gaps += 1;
310            j -= 1;
311        }
312    }
313    aq.reverse();
314    at.reverse();
315    let aln_len = aq.len();
316    AlignmentResult {
317        score: dp[m][n],
318        identity_pct: if aln_len > 0 {
319            100.0 * matches as f32 / aln_len as f32
320        } else {
321            0.0
322        },
323        aligned_query: aq,
324        aligned_target: at,
325        num_matches: matches,
326        num_gaps: gaps,
327    }
328}
329
330// ─── Convenience entry points ─────────────────────────────────────────────────
331
332/// DNA/RNA Smith-Waterman with BLAST nucleotide defaults.
333pub fn align_nucleotide(query: &[u8], target: &[u8]) -> AlignmentResult {
334    let mat = NucleotideMatrix::default();
335    smith_waterman(query, target, GapPenalty::default(), move |a, b| {
336        if a.to_ascii_uppercase() == b.to_ascii_uppercase() {
337            mat.match_score
338        } else {
339            mat.mismatch_score
340        }
341    })
342}
343
344/// Protein Smith-Waterman with BLOSUM62.
345pub fn align_protein(query: &[u8], target: &[u8]) -> AlignmentResult {
346    smith_waterman(query, target, GapPenalty::default(), blosum62_score)
347}
348
349/// Backward-compatible entry point returning the legacy `AlignmentScore`.
350pub fn align_sequences(query: &[u8], target: &[u8]) -> AlignmentScore {
351    #[cfg(all(feature = "neon_simd_unroll", target_arch = "x86_64"))]
352    {
353        return simd_align_x86_64(query, target);
354    }
355
356    #[cfg(all(feature = "neon_simd_unroll", target_arch = "aarch64"))]
357    {
358        return simd_align_aarch64(query, target);
359    }
360
361    AlignmentScore {
362        score: align_nucleotide(query, target).score,
363    }
364}
365
366// ─── K-mer analysis ───────────────────────────────────────────────────────────
367
368/// Counts all k-mer occurrences; returns (kmer_fnv1a_hash, count) sorted by hash.
369pub fn kmer_frequencies(sequence: &[u8], k: usize) -> Vec<(u64, u32)> {
370    if k == 0 || k > sequence.len() {
371        return vec![];
372    }
373    let mut counts = std::collections::HashMap::<u64, u32>::new();
374    for window in sequence.windows(k) {
375        let hash = window.iter().fold(0xcbf29ce484222325u64, |h, &b| {
376            (h ^ b.to_ascii_uppercase() as u64).wrapping_mul(0x100000001b3)
377        });
378        *counts.entry(hash).or_insert(0) += 1;
379    }
380    let mut out: Vec<(u64, u32)> = counts.into_iter().collect();
381    out.sort_unstable_by_key(|&(h, _)| h);
382    out
383}
384
385/// MinHash sketch: the `sketch_size` smallest k-mer hashes.
386pub fn minhash_sketch(sequence: &[u8], k: usize, sketch_size: usize) -> Vec<u64> {
387    let mut hashes: Vec<u64> = kmer_frequencies(sequence, k)
388        .into_iter()
389        .map(|(h, _)| h)
390        .collect();
391    hashes.sort_unstable();
392    hashes.truncate(sketch_size);
393    hashes
394}
395
396/// Jaccard similarity (0.0–1.0) between two MinHash sketches.
397pub fn jaccard_similarity(a: &[u64], b: &[u64]) -> f32 {
398    if a.is_empty() && b.is_empty() {
399        return 1.0;
400    }
401    let intersection = a.iter().filter(|&&x| b.binary_search(&x).is_ok()).count();
402    let union = a.len() + b.len() - intersection;
403    if union == 0 {
404        1.0
405    } else {
406        intersection as f32 / union as f32
407    }
408}
409
410// ─── Phylogenetic tree construction (UPGMA, bounded memory) ────────────────────
411
412/// Maximum number of taxa (leaves) a single bounded UPGMA build admits. Bounding
413/// the taxon count keeps the working distance matrix on the stack — zero heap.
414pub const MAX_PHYLO_TAXA: usize = 64;
415
416/// One agglomeration event in a UPGMA tree: the two child cluster ids merged, the
417/// node height, and the id assigned to the new (parent) cluster. Leaves are
418/// `0..n`; internal nodes are numbered `n..2n-1` in merge order, so the last
419/// record's `merged_id` is the root.
420#[derive(Debug, Clone, Copy, PartialEq)]
421pub struct PhyloMerge {
422    pub cluster_a: u16,
423    pub cluster_b: u16,
424    pub height: f32,
425    pub merged_id: u16,
426}
427
428/// Build a UPGMA (Unweighted Pair Group Method with Arithmetic Mean) phylogenetic
429/// tree from a flattened `n×n` symmetric distance matrix (`distances[i*n + j]`).
430///
431/// Writes the `n-1` merge events into `out` (a rooted binary tree) and returns the
432/// count. UPGMA repeatedly joins the closest pair of clusters at height `d/2` and
433/// updates distances to the merged cluster as the size-weighted arithmetic mean.
434/// **Strictly bounded memory:** fixed `MAX_PHYLO_TAXA`-sized stack arrays, no heap,
435/// no recursion. Returns 0 if `n` is < 2 or exceeds `MAX_PHYLO_TAXA`, or if `out`
436/// is too small.
437pub fn build_upgma_tree(distances: &[f32], n: usize, out: &mut [PhyloMerge]) -> usize {
438    if n < 2 || n > MAX_PHYLO_TAXA || distances.len() < n * n || out.len() < n - 1 {
439        return 0;
440    }
441
442    // Working distance matrix between *clusters* (initialised from the leaf matrix).
443    let mut d = [[0f32; MAX_PHYLO_TAXA]; MAX_PHYLO_TAXA];
444    for i in 0..n {
445        for j in 0..n {
446            d[i][j] = distances[i * n + j];
447        }
448    }
449    let mut active = [true; MAX_PHYLO_TAXA];
450    let mut size = [1u32; MAX_PHYLO_TAXA];
451    let mut id = [0u16; MAX_PHYLO_TAXA]; // current cluster id occupying each slot
452    for (i, slot) in id.iter_mut().enumerate().take(n) {
453        *slot = i as u16;
454    }
455
456    let mut written = 0usize;
457    let mut next_id = n as u16;
458
459    for _ in 0..n - 1 {
460        // Closest active pair.
461        let mut best = f32::INFINITY;
462        let mut bi = 0usize;
463        let mut bj = 0usize;
464        for i in 0..n {
465            if !active[i] {
466                continue;
467            }
468            for j in (i + 1)..n {
469                if active[j] && d[i][j] < best {
470                    best = d[i][j];
471                    bi = i;
472                    bj = j;
473                }
474            }
475        }
476
477        out[written] = PhyloMerge {
478            cluster_a: id[bi],
479            cluster_b: id[bj],
480            height: best / 2.0, // UPGMA node height
481            merged_id: next_id,
482        };
483        written += 1;
484
485        // Merge bj into bi: size-weighted arithmetic mean of distances to every other
486        // active cluster, then retire bj.
487        let (si, sj) = (size[bi] as f32, size[bj] as f32);
488        for k in 0..n {
489            if k == bi || k == bj || !active[k] {
490                continue;
491            }
492            let merged = (si * d[bi][k] + sj * d[bj][k]) / (si + sj);
493            d[bi][k] = merged;
494            d[k][bi] = merged;
495        }
496        size[bi] += size[bj];
497        id[bi] = next_id;
498        active[bj] = false;
499        next_id += 1;
500    }
501
502    written
503}
504
505// ─── FASTA validation ─────────────────────────────────────────────────────────
506
507#[derive(Debug, Clone, PartialEq, Eq)]
508pub enum SequenceAlphabet {
509    DNA,
510    RNA,
511    Protein,
512    Unknown,
513}
514
515#[derive(Debug, Clone)]
516pub struct FastaRecord {
517    pub header: String,
518    pub sequence: Vec<u8>,
519    pub alphabet: SequenceAlphabet,
520    pub is_valid: bool,
521    pub invalid_chars: Vec<char>,
522}
523
524/// Validates and classifies a FASTA record.
525pub fn validate_fasta_record(header: &str, sequence: &[u8]) -> FastaRecord {
526    let dna_alphabet: &[u8] = b"ACGTNacgtn-";
527    let rna_alphabet: &[u8] = b"ACGUNacgun-";
528    let protein_alphabet: &[u8] = b"ACDEFGHIKLMNPQRSTVWYXacdefghiklmnpqrstvwyx*-";
529
530    let is_dna = sequence.iter().all(|c| dna_alphabet.contains(c));
531    let is_rna = sequence.iter().all(|c| rna_alphabet.contains(c));
532    let is_protein = sequence.iter().all(|c| protein_alphabet.contains(c));
533
534    let mut invalid: Vec<char> = sequence
535        .iter()
536        .filter(|c| !protein_alphabet.contains(c))
537        .map(|&c| c as char)
538        .collect();
539    invalid.dedup();
540
541    let alphabet = if is_dna {
542        SequenceAlphabet::DNA
543    } else if is_rna {
544        SequenceAlphabet::RNA
545    } else if is_protein {
546        SequenceAlphabet::Protein
547    } else {
548        SequenceAlphabet::Unknown
549    };
550
551    FastaRecord {
552        header: header.to_string(),
553        sequence: sequence.to_vec(),
554        is_valid: invalid.is_empty() && !sequence.is_empty() && !header.is_empty(),
555        invalid_chars: invalid,
556        alphabet,
557    }
558}
559
560// ─── Metabolite fingerprint similarity ───────────────────────────────────────
561
562/// Tanimoto (Jaccard) coefficient between two Morgan fingerprints encoded as u64 bitmasks.
563/// Multiple words can represent a full extended fingerprint.
564#[inline]
565pub fn tanimoto_similarity(fp_a: &[u64], fp_b: &[u64]) -> f32 {
566    assert_eq!(fp_a.len(), fp_b.len(), "fingerprint lengths must match");
567    let intersection: u32 = fp_a
568        .iter()
569        .zip(fp_b)
570        .map(|(a, b)| (a & b).count_ones())
571        .sum();
572    let union: u32 = fp_a
573        .iter()
574        .zip(fp_b)
575        .map(|(a, b)| (a | b).count_ones())
576        .sum();
577    if union == 0 {
578        1.0
579    } else {
580        intersection as f32 / union as f32
581    }
582}
583
584/// Dice coefficient between two binary fingerprints.
585#[inline]
586pub fn dice_similarity(fp_a: &[u64], fp_b: &[u64]) -> f32 {
587    assert_eq!(fp_a.len(), fp_b.len());
588    let intersection: u32 = fp_a
589        .iter()
590        .zip(fp_b)
591        .map(|(a, b)| (a & b).count_ones())
592        .sum();
593    let sum_a: u32 = fp_a.iter().map(|a| a.count_ones()).sum();
594    let sum_b: u32 = fp_b.iter().map(|b| b.count_ones()).sum();
595    if sum_a + sum_b == 0 {
596        1.0
597    } else {
598        2.0 * intersection as f32 / (sum_a + sum_b) as f32
599    }
600}
601
602// ─── SIMD fast-paths (feature-gated) ───────────────────────────────────
603
604#[cfg(all(feature = "neon_simd_unroll", target_arch = "x86_64"))]
605pub fn simd_align_x86_64(query: &[u8], target: &[u8]) -> AlignmentScore {
606    if std::is_x86_feature_detected!("avx2") {
607        // SAFETY: the AVX2 probe dominates the isolated kernel call.
608        unsafe { simd_align_x86_64_avx2(query, target) }
609    } else {
610        AlignmentScore {
611            score: align_nucleotide(query, target).score,
612        }
613    }
614}
615
616#[cfg(all(feature = "neon_simd_unroll", target_arch = "x86_64"))]
617#[target_feature(enable = "avx2")]
618unsafe fn simd_align_x86_64_avx2(query: &[u8], target: &[u8]) -> AlignmentScore {
619    use std::arch::x86_64::*;
620    let min_len = query.len().min(target.len());
621    let mut score = 0i32;
622    let mut i = 0;
623
624    // Exact match fast-path using AVX2 (256-bit / 32-byte chunks)
625    while i + 32 <= min_len {
626        let q_vec = _mm256_loadu_si256(query.as_ptr().add(i) as *const __m256i);
627        let t_vec = _mm256_loadu_si256(target.as_ptr().add(i) as *const __m256i);
628        let cmp = _mm256_cmpeq_epi8(q_vec, t_vec);
629        let mask = _mm256_movemask_epi8(cmp);
630        let matches = mask.count_ones() as i32;
631        let mismatches = 32 - matches;
632
633        score += matches * 2;
634        score -= mismatches * 3;
635        i += 32;
636    }
637
638    if i < min_len {
639        score += align_nucleotide(&query[i..], &target[i..]).score;
640    }
641    AlignmentScore { score }
642}
643
644#[cfg(all(feature = "neon_simd_unroll", target_arch = "aarch64"))]
645pub fn simd_align_aarch64(query: &[u8], target: &[u8]) -> AlignmentScore {
646    #[cfg(target_feature = "neon")]
647    unsafe {
648        use std::arch::aarch64::*;
649        let min_len = query.len().min(target.len());
650        let mut score = 0i32;
651        let mut i = 0;
652
653        // Exact match fast-path using NEON (128-bit / 16-byte chunks)
654        while i + 16 <= min_len {
655            let q_vec = vld1q_u8(query.as_ptr().add(i));
656            let t_vec = vld1q_u8(target.as_ptr().add(i));
657            let cmp = vceqq_u8(q_vec, t_vec);
658
659            let mut v = [0u8; 16];
660            vst1q_u8(v.as_mut_ptr(), cmp);
661            let mut matches = 0;
662            for &b in &v {
663                if b == 0xFF {
664                    matches += 1;
665                }
666            }
667            let mismatches = 16 - matches;
668
669            score += matches * 2;
670            score -= mismatches * 3;
671            i += 16;
672        }
673
674        if i < min_len {
675            score += align_nucleotide(&query[i..], &target[i..]).score;
676        }
677        return AlignmentScore { score };
678    }
679
680    #[cfg(not(target_feature = "neon"))]
681    AlignmentScore {
682        score: align_nucleotide(query, target).score,
683    }
684}
685
686// ─── DNA to Protein Translation ──────────────────────────────────────────────
687
688/// Translates a DNA sequence into an amino acid sequence using the standard genetic code.
689/// Writes directly into the caller-provided `out` buffer to avoid allocation.
690/// Returns the number of amino acids written.
691pub fn translate_dna_to_protein(dna: &[u8], out: &mut [u8]) -> usize {
692    let mut written = 0;
693    for i in (0..dna.len()).step_by(3) {
694        if i + 2 >= dna.len() {
695            break;
696        }
697        if written >= out.len() {
698            break;
699        }
700
701        let codon = (
702            dna[i].to_ascii_uppercase(),
703            dna[i + 1].to_ascii_uppercase(),
704            dna[i + 2].to_ascii_uppercase(),
705        );
706        let aa = match codon {
707            (b'G', b'C', _) => b'A',                         // Alanine
708            (b'T', b'G', b'C') | (b'T', b'G', b'T') => b'C', // Cysteine
709            (b'G', b'A', b'C') | (b'G', b'A', b'T') => b'D', // Aspartic Acid
710            (b'G', b'A', b'A') | (b'G', b'A', b'G') => b'E', // Glutamic Acid
711            (b'T', b'T', b'C') | (b'T', b'T', b'T') => b'F', // Phenylalanine
712            (b'G', b'G', _) => b'G',                         // Glycine
713            (b'C', b'A', b'C') | (b'C', b'A', b'T') => b'H', // Histidine
714            (b'A', b'T', b'C') | (b'A', b'T', b'T') | (b'A', b'T', b'A') => b'I', // Isoleucine
715            (b'A', b'A', b'A') | (b'A', b'A', b'G') => b'K', // Lysine
716            (b'C', b'T', _) | (b'T', b'T', b'A') | (b'T', b'T', b'G') => b'L', // Leucine
717            (b'A', b'T', b'G') => b'M',                      // Methionine (Start)
718            (b'A', b'A', b'C') | (b'A', b'A', b'T') => b'N', // Asparagine
719            (b'C', b'C', _) => b'P',                         // Proline
720            (b'C', b'A', b'A') | (b'C', b'A', b'G') => b'Q', // Glutamine
721            (b'C', b'G', _) | (b'A', b'G', b'A') | (b'A', b'G', b'G') => b'R', // Arginine
722            (b'T', b'C', _) | (b'A', b'G', b'C') | (b'A', b'G', b'T') => b'S', // Serine
723            (b'A', b'C', _) => b'T',                         // Threonine
724            (b'G', b'T', _) => b'V',                         // Valine
725            (b'T', b'G', b'G') => b'W',                      // Tryptophan
726            (b'T', b'A', b'C') | (b'T', b'A', b'T') => b'Y', // Tyrosine
727            (b'T', b'A', b'A') | (b'T', b'A', b'G') | (b'T', b'G', b'A') => b'*', // Stop
728            _ => b'X',                                       // Unknown
729        };
730        out[written] = aa;
731        written += 1;
732    }
733    written
734}
735
736// ─── Protein Isoelectric Point ───────────────────────────────────────────────
737
738/// Estimates the Isoelectric Point (pI) of a protein sequence using
739/// the Henderson-Hasselbalch equation and basic pKa values.
740pub fn calculate_isoelectric_point(protein: &[u8]) -> f64 {
741    let c_term = 1; // Alpha-COOH
742    let n_term = 1; // Alpha-NH2
743    let mut d = 0; // Aspartic acid (D)
744    let mut e = 0; // Glutamic acid (E)
745    let mut c = 0; // Cysteine (C)
746    let mut y = 0; // Tyrosine (Y)
747    let mut h = 0; // Histidine (H)
748    let mut k = 0; // Lysine (K)
749    let mut r = 0; // Arginine (R)
750
751    for &aa in protein {
752        match aa.to_ascii_uppercase() {
753            b'D' => d += 1,
754            b'E' => e += 1,
755            b'C' => c += 1,
756            b'Y' => y += 1,
757            b'H' => h += 1,
758            b'K' => k += 1,
759            b'R' => r += 1,
760            _ => {}
761        }
762    }
763
764    // pKa values (Bjellqvist etc. simplified)
765    let pka_c_term = 3.65;
766    let pka_d = 3.90;
767    let pka_e = 4.07;
768    let pka_c = 8.18;
769    let pka_y = 10.46;
770
771    let pka_n_term = 8.20;
772    let pka_h = 6.04;
773    let pka_k = 10.53;
774    let pka_r = 12.48;
775
776    let net_charge = |ph: f64| -> f64 {
777        let neg = (c_term as f64) / (1.0 + 10.0_f64.powf(pka_c_term - ph))
778            + (d as f64) / (1.0 + 10.0_f64.powf(pka_d - ph))
779            + (e as f64) / (1.0 + 10.0_f64.powf(pka_e - ph))
780            + (c as f64) / (1.0 + 10.0_f64.powf(pka_c - ph))
781            + (y as f64) / (1.0 + 10.0_f64.powf(pka_y - ph));
782
783        let pos = (n_term as f64) / (1.0 + 10.0_f64.powf(ph - pka_n_term))
784            + (h as f64) / (1.0 + 10.0_f64.powf(ph - pka_h))
785            + (k as f64) / (1.0 + 10.0_f64.powf(ph - pka_k))
786            + (r as f64) / (1.0 + 10.0_f64.powf(ph - pka_r));
787
788        pos - neg
789    };
790
791    // Bisection method to find pH where net_charge == 0
792    let mut low = 0.0;
793    let mut high = 14.0;
794    for _ in 0..50 {
795        let mid = (low + high) / 2.0;
796        let charge = net_charge(mid);
797        if charge > 0.0 {
798            low = mid;
799        } else {
800            high = mid;
801        }
802    }
803    (low + high) / 2.0
804}
805
806// ─── Peptide Cleavage Prediction ─────────────────────────────────────────────
807
808/// Predicts cleavage sites for Trypsin (cleaves after K or R, unless followed by P).
809/// Writes the indices of cleavage into the caller-provided `out_indices` buffer.
810/// Returns the number of cleavage sites found.
811pub fn predict_peptide_cleavage(protein: &[u8], out_indices: &mut [usize]) -> usize {
812    let mut count = 0;
813    for i in 0..protein.len() {
814        if count >= out_indices.len() {
815            break;
816        }
817        let aa = protein[i].to_ascii_uppercase();
818        if aa == b'K' || aa == b'R' {
819            if i + 1 < protein.len() && protein[i + 1].to_ascii_uppercase() == b'P' {
820                continue; // Trypsin generally doesn't cleave if followed by Proline
821            }
822            out_indices[count] = i;
823            count += 1;
824        }
825    }
826    count
827}
828
829// ─── Tests ────────────────────────────────────────────────────────────────────
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834
835    #[test]
836    fn sw_identical_nucleotide() {
837        let r = align_nucleotide(b"ACGTACGT", b"ACGTACGT");
838        assert!(r.score > 0);
839        assert!((r.identity_pct - 100.0).abs() < 0.01);
840    }
841
842    #[test]
843    fn sw_one_mismatch() {
844        let r = align_nucleotide(b"ACGTACGT", b"ACGTCCGT");
845        assert!(r.score > 0);
846        assert!(r.identity_pct > 80.0);
847    }
848
849    #[test]
850    fn blosum62_diagonal_positive() {
851        for aa in b"ACDEFGHIKLMNPQRSTVWY" {
852            assert!(
853                blosum62_score(*aa, *aa) > 0,
854                "diagonal should be positive for {}",
855                *aa as char
856            );
857        }
858    }
859
860    #[test]
861    fn blosum62_w_max_diagonal() {
862        assert_eq!(blosum62_score(b'W', b'W'), 11);
863    }
864
865    #[test]
866    fn protein_align_identical() {
867        let r = align_protein(b"ACDEFGHIK", b"ACDEFGHIK");
868        assert!(r.score > 0);
869        assert!((r.identity_pct - 100.0).abs() < 0.01);
870    }
871
872    #[test]
873    fn nw_global_fills_gaps() {
874        let mat = NucleotideMatrix::default();
875        let r = needleman_wunsch(b"ACGT", b"ACGTTTT", GapPenalty::default(), move |a, b| {
876            if a.to_ascii_uppercase() == b.to_ascii_uppercase() {
877                mat.match_score
878            } else {
879                mat.mismatch_score
880            }
881        });
882        assert_eq!(r.aligned_query.len(), r.aligned_target.len());
883    }
884
885    #[test]
886    fn kmer_frequency_counts() {
887        let f = kmer_frequencies(b"ATCGATCG", 3);
888        assert!(!f.is_empty());
889        let total: u32 = f.iter().map(|&(_, c)| c).sum();
890        assert_eq!(total, 6); // 8 - 3 + 1 = 6 tri-mers
891    }
892
893    #[test]
894    fn fasta_dna_valid() {
895        let r = validate_fasta_record(">seq1", b"ATCGATCG");
896        assert_eq!(r.alphabet, SequenceAlphabet::DNA);
897        assert!(r.is_valid);
898    }
899
900    #[test]
901    fn invalid_fasta_alphabets() {
902        let rec = validate_fasta_record(">test", b"ATCGXZATCG");
903        assert!(!rec.is_valid);
904    }
905
906    #[test]
907    fn test_translate_dna() {
908        let dna = b"ATGGCCATTGTAATGGGCCGCTGAAAGGGTGCCCGATAG";
909        let mut out = [0u8; 100];
910        let n = translate_dna_to_protein(dna, &mut out);
911        assert_eq!(&out[..n], b"MAIVMGR*KGAR*");
912    }
913
914    #[test]
915    fn test_isoelectric_point() {
916        let protein = b"MGRKGAR"; // basic protein, expect high pI
917        let pi = calculate_isoelectric_point(protein);
918        assert!(pi > 10.0, "pI was {}", pi);
919    }
920
921    #[test]
922    fn test_peptide_cleavage() {
923        let protein = b"MGRKGAR"; // R at 2, K at 3, R at 6
924        let mut out = [0usize; 10];
925        let n = predict_peptide_cleavage(protein, &mut out);
926        assert_eq!(n, 3);
927        assert_eq!(&out[..n], &[2, 3, 6]);
928    }
929
930    #[test]
931    fn fasta_invalid_chars() {
932        let r = validate_fasta_record(">seq2", b"ATCG123");
933        assert!(!r.is_valid);
934        assert!(!r.invalid_chars.is_empty());
935    }
936
937    #[test]
938    fn tanimoto_identical() {
939        let fp = vec![0xFFFFFFFFFFFFFFFFu64; 2];
940        assert!((tanimoto_similarity(&fp, &fp) - 1.0).abs() < 1e-6);
941    }
942
943    #[test]
944    fn tanimoto_disjoint() {
945        let a = vec![0x00000000FFFFFFFFu64];
946        let b = vec![0xFFFFFFFF00000000u64];
947        assert!((tanimoto_similarity(&a, &b)).abs() < 1e-6);
948    }
949
950    #[test]
951    fn upgma_builds_the_expected_nested_tree() {
952        // Two tight pairs (0,1) and (2,3) that are far from each other.
953        //       0  1  2  3
954        //   0 [ 0  2  6  6 ]
955        //   1 [ 2  0  6  6 ]
956        //   2 [ 6  6  0  4 ]
957        //   3 [ 6  6  4  0 ]
958        // UPGMA: join (0,1)@1, then (2,3)@2, then the two clusters @3.
959        #[rustfmt::skip]
960        let d = [
961            0.0, 2.0, 6.0, 6.0,
962            2.0, 0.0, 6.0, 6.0,
963            6.0, 6.0, 0.0, 4.0,
964            6.0, 6.0, 4.0, 0.0,
965        ];
966        let mut out = [PhyloMerge {
967            cluster_a: 0,
968            cluster_b: 0,
969            height: 0.0,
970            merged_id: 0,
971        }; 8];
972        let n = build_upgma_tree(&d, 4, &mut out);
973        assert_eq!(n, 3, "n-1 merges for 4 taxa");
974
975        // First merge: leaves 0 and 1 at height 1, new cluster id 4.
976        assert_eq!((out[0].cluster_a, out[0].cluster_b), (0, 1));
977        assert!((out[0].height - 1.0).abs() < 1e-6);
978        assert_eq!(out[0].merged_id, 4);
979
980        // Second merge: leaves 2 and 3 at height 2, new cluster id 5.
981        assert_eq!((out[1].cluster_a, out[1].cluster_b), (2, 3));
982        assert!((out[1].height - 2.0).abs() < 1e-6);
983        assert_eq!(out[1].merged_id, 5);
984
985        // Root merge: clusters 4 and 5 at height 3 (avg cross distance 6 / 2).
986        assert_eq!((out[2].cluster_a, out[2].cluster_b), (4, 5));
987        assert!((out[2].height - 3.0).abs() < 1e-6);
988        assert_eq!(out[2].merged_id, 6, "last merged_id is the root");
989    }
990
991    #[test]
992    fn upgma_rejects_degenerate_input() {
993        let mut out = [PhyloMerge {
994            cluster_a: 0,
995            cluster_b: 0,
996            height: 0.0,
997            merged_id: 0,
998        }; 8];
999        assert_eq!(build_upgma_tree(&[0.0], 1, &mut out), 0, "n<2 rejected");
1000        // out too small for n-1 merges
1001        let d = [0.0, 1.0, 1.0, 0.0];
1002        let mut tiny = [PhyloMerge {
1003            cluster_a: 0,
1004            cluster_b: 0,
1005            height: 0.0,
1006            merged_id: 0,
1007        }; 0];
1008        assert_eq!(
1009            build_upgma_tree(&d, 2, &mut tiny),
1010            0,
1011            "undersized out rejected"
1012        );
1013    }
1014}