Skip to main content

mafft_core/
adjust_direction.rs

1//! Port of C MAFFT's `--adjustdirection` strand-detection preprocessing.
2//!
3//! The C pipeline (`scripts/mafft:2323-2342`) runs two helper binaries
4//! before any alignment:
5//! 1. `makedirectionlist` — k-mer-based scoring of every DNA sequence
6//!    against forward AND reverse-complement orientations of already
7//!    examined sequences. Emits a per-sequence `_F_`/`_R_` direction
8//!    file.
9//! 2. `setdirection` — reads that file, reverse-complements sequences
10//!    flagged `R`, prefixes their names with `_R_`.
11//!
12//! This module reproduces both steps in one function:
13//! [`adjust_direction`], operating directly on the [`SequenceSet`] the
14//! engine receives. Protein inputs pass through unchanged. The 6-mer
15//! mode (default `--adjustdirection`, C's `makedirectionlist -m -o a`)
16//! is the only mode implemented here — the DP-based `-d` mode is
17//! `--adjustdirectionaccurately` and tracked separately.
18//!
19//! ## Algorithm (6-mer, `mode='a'` averaging — `makedirectionlist.c:1217-1226`)
20//!
21//! ```text
22//! contrastorder = argsort(forward_self - reverse_self) desc        // "most directional first"
23//! direction[contrastorder[0]] = F                                  // pivot
24//! for i in 1..N (in contrastorder):
25//!     ic = contrastorder[i]
26//!     for each previously-decided j (limit reflim, default 5000):
27//!         resf[j] = common_6mers(comp_table(forward(ic)),  chosen_pointt(j))
28//!         resr[j] = common_6mers(comp_table(reverse(ic)),  chosen_pointt(j))
29//!     if mean(resr) > mean(resf):
30//!         direction[ic] = R
31//!     else:
32//!         direction[ic] = F
33//! if direction[0] == R:                                            // makedirectionlist.c:1261-1270
34//!     flip all
35//! ```
36//!
37//! The k-mer encoding and `common_sextets_p` primitive are reused from
38//! [`mafft_tree::parttree_dist`].
39
40use mafft_align::{local_align, GapModel};
41use mafft_scoring::build_context;
42use mafft_tree::parttree_dist::{
43    common_sextets_p, composition_table, encode_points_dna,
44};
45use mafft_types::{ScoringModel, Sequence, SeqType, SequenceSet};
46
47/// `--adjustdirection` reference cap (`scripts/mafft:2331` `-r 5000`)
48/// for the 6-mer mode.
49const REFERENCE_LIMIT_KMER: usize = 5000;
50
51/// `--adjustdirectionaccurately` reference cap (`scripts/mafft:2333`
52/// `-r 100`) for the DP mode.
53const REFERENCE_LIMIT_DP: usize = 100;
54
55/// `mafft-upstream/core/makedirectionlist.c:464` — tsize for DNA, `pow(4, 6) = 4096`.
56const TSIZE: usize = 4096;
57
58/// Which scoring mode the direction adjustment uses.
59///
60/// - [`Kmer`] (`--adjustdirection`): 6-mer composition overlap via
61///   `common_sextets_p` — fast, default.
62/// - [`Dp`] (`--adjustdirectionaccurately`): local pairwise alignment
63///   score via `local_align` — slower (~N^2 DP) but uses the
64///   real substitution scores so it's more robust on divergent
65///   sequences.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum AdjustMode {
68    Kmer,
69    Dp,
70}
71
72/// `mafft-upstream/core/io.c::creverse` complement table (DNA + RNA + IUPAC).
73/// Identity for everything outside the table; gap chars left alone.
74fn creverse(c: u8) -> u8 {
75    match c {
76        b'A' => b'T', b'C' => b'G', b'G' => b'C', b'T' => b'A', b'U' => b'A',
77        b'M' => b'K', b'R' => b'Y', b'W' => b'W', b'S' => b'S', b'Y' => b'R',
78        b'K' => b'M', b'V' => b'B', b'H' => b'D', b'D' => b'H', b'B' => b'V',
79        b'N' => b'N',
80        b'a' => b't', b'c' => b'g', b'g' => b'c', b't' => b'a', b'u' => b'a',
81        b'm' => b'k', b'r' => b'y', b'w' => b'w', b's' => b's', b'y' => b'r',
82        b'k' => b'm', b'v' => b'b', b'h' => b'd', b'd' => b'h', b'b' => b'v',
83        b'n' => b'n',
84        other => other,
85    }
86}
87
88/// Reverse-complement a DNA sequence (mirrors `io.c::sreverse`).
89///
90/// C also flips `T`↔`U` when the input had more U than T (treats it as
91/// RNA), but the adjustdirection caller has already gappicked and
92/// case-preserved its input via `mafft-io`, and we keep the original
93/// alphabet to stay byte-identical with C's setdirection output.
94pub fn reverse_complement(seq: &[u8]) -> Vec<u8> {
95    let mut out = Vec::with_capacity(seq.len());
96    for &c in seq.iter().rev() {
97        out.push(creverse(c));
98    }
99    let num_t = seq.iter().filter(|&&c| c == b't' || c == b'T').count();
100    let num_u = seq.iter().filter(|&&c| c == b'u' || c == b'U').count();
101    if num_u > num_t {
102        // `io.c::ttou`: RNA input — convert T→U on the complement.
103        for c in &mut out {
104            if *c == b't' { *c = b'u'; }
105            else if *c == b'T' { *c = b'U'; }
106        }
107    }
108    out
109}
110
111/// Strip gap characters AND normalize to lowercase. Port of
112/// `mltaln9.c::gappick0` with an added case-normalization that
113/// matters when this function is fed mixed-case input (e.g.,
114/// `--add` combines an existing alignment read with
115/// `--preservecase` and an addfile read without it). The DNA
116/// scoring matrix only populates the lowercase 0-4 and uppercase
117/// 5-9 sub-blocks separately — cross-case cells `[0..5][5..10]`
118/// are zero — so without normalization `local_align` between
119/// lowercase and uppercase versions of the same sequence returns
120/// score 0. C MAFFT's `getnumlen_casepreserve` only flips case on
121/// recognised gap chars, so cross-case is also possible in C
122/// but the makedirectionlist invocation runs `getnumlen` (not
123/// the case-preserving variant) which uppercases everything.
124fn gappick0(seq: &[u8]) -> Vec<u8> {
125    seq.iter()
126        .copied()
127        .filter(|&c| c != b'-' && c != b'.')
128        .map(|c| c.to_ascii_lowercase())
129        .collect()
130}
131
132/// Per-position decision result, plus the orientation actually fed to
133/// the alignment engine.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum Direction { Forward, Reverse }
136
137/// Convenience wrapper for the default `--adjustdirection` (k-mer)
138/// mode. See [`adjust_direction_mode`] for the full API.
139pub fn adjust_direction(input: &SequenceSet) -> SequenceSet {
140    adjust_direction_mode(input, AdjustMode::Kmer)
141}
142
143/// Like [`adjust_direction_mode`] but only re-orients the LAST `nadd`
144/// sequences of the input — the first `nseq - nadd` are anchored
145/// as Forward and serve as references. Used when the caller is
146/// preparing input for `--add` / `--addfragments`: C MAFFT's
147/// `makedirectionlist.c:881-917` slices the per-sequence pointt
148/// build by `nadd`, and `makedirectionlist.c:925-936` constrains
149/// contrastsort to the added subset.
150///
151/// `nadd == 0` is equivalent to plain [`adjust_direction_mode`].
152pub fn adjust_direction_mode_add(
153    input: &SequenceSet,
154    mode: AdjustMode,
155    nadd: usize,
156) -> SequenceSet {
157    adjust_direction_with(input, mode, nadd)
158}
159
160/// Run direction adjustment over `input` in the requested mode and
161/// return a new [`SequenceSet`] with chosen orientations applied
162/// and names prefixed by `_R_` where reversed. Protein inputs are
163/// returned unchanged.
164///
165/// On the typical DNA flow, only sequence 0 stays with its original
166/// name (matching `setdirection.c:138-155`).
167pub fn adjust_direction_mode(input: &SequenceSet, mode: AdjustMode) -> SequenceSet {
168    adjust_direction_with(input, mode, 0)
169}
170
171/// Implementation backing [`adjust_direction_mode`] and
172/// [`adjust_direction_mode_add`].
173fn adjust_direction_with(input: &SequenceSet, mode: AdjustMode, nadd: usize) -> SequenceSet {
174    if !input.seq_type.is_nucleotide() {
175        return input.clone();
176    }
177
178    let nseq = input.nseq();
179    if nseq == 0 {
180        return input.clone();
181    }
182
183    // Gappicked forward sequences (analogous to C's `gappick0` pre-pass).
184    let forward: Vec<Vec<u8>> = input.sequences.iter()
185        .map(|s| gappick0(&s.data))
186        .collect();
187    let reverse: Vec<Vec<u8>> = forward.iter()
188        .map(|s| reverse_complement(s))
189        .collect();
190
191    // For the DP mode (`--adjustdirectionaccurately`) we need a DNA
192    // scoring context. The k-mer mode never touches scoring.
193    let scoring = if mode == AdjustMode::Dp {
194        Some(build_context(ScoringModel::Dna, SeqType::Dna))
195    } else {
196        None
197    };
198    // C `Lalign11.c::L__align11_noalign` reads `penalty`/`penalty_ex`
199    // globals set by `constants()`. For the makedirectionlist DNA
200    // path that's `DEFAULTGOP_N` / `DEFAULTGEP_N` post-scaling — the
201    // same values `build_context` populates into `scoring.gap`.
202    let gap_dp = scoring.as_ref().map(|s| {
203        GapModel::new(s.gap.open as f64, s.gap.extend as f64)
204    });
205
206    // Step 1: build forward + reverse-complement 6-mer point vectors
207    // (port of `makedirectionlist.c::makepointtable_nuc` calls at lines
208    // 905-909). Empty point vector means the sequence had fewer than
209    // 6 unambiguous bases; treat that sequence as forward. Used by
210    // BOTH modes — the DP mode still uses k-mer contrast for the
211    // initial ordering of sequences (C `makedirectionlist.c:937-941`
212    // dispatches `makecontrastorder` for `dodp`, but that function
213    // also reduces to a forward-vs-reverse self-score difference,
214    // and is independent of the per-pair scoring used in step 3).
215    let points_fwd: Vec<Vec<u32>> = forward.iter().map(|s| encode_points_dna(s)).collect();
216    let points_rev: Vec<Vec<u32>> = reverse.iter().map(|s| encode_points_dna(s)).collect();
217
218    // `n_anchor` = number of existing (pre-added) sequences that are
219    // FORCED forward and used as references. For `--add` mode this
220    // is `njob - nadd`; without `--add` it's 0 (so step 0 is the
221    // pivot — matches C `makedirectionlist.c:881-984`'s
222    // `if (nadd) ... else iend = 0/1` slicing).
223    let n_anchor = if nadd > 0 && nadd <= nseq { nseq - nadd } else { 0 };
224
225    // Step 2: contrastsort over the testable subset only. C
226    // `makedirectionlist.c:925-941` runs `makecontrastorder*` on
227    // `contrastorder + istart`, where `istart = njob - nadd`
228    // (or 0 without --add). Anchors keep their natural index
229    // order at the front of `contrast_order`.
230    let mut contrast_order: Vec<(usize, f64)> = Vec::with_capacity(nseq);
231    for i in 0..n_anchor { contrast_order.push((i, 0.0)); }
232    let mut testable: Vec<(usize, f64)> = match mode {
233        AdjustMode::Kmer => (n_anchor..nseq).map(|i| {
234            let p_fwd = &points_fwd[i];
235            let p_rev = &points_rev[i];
236            let t_fwd = composition_table(p_fwd, TSIZE);
237            let t_rev = composition_table(p_rev, TSIZE);
238            let dif = (common_sextets_p(&t_fwd, p_fwd, TSIZE)
239                     - common_sextets_p(&t_rev, p_fwd, TSIZE)) as f64;
240            (i, dif)
241        }).collect(),
242        AdjustMode::Dp => {
243            let sc = scoring.as_ref().unwrap();
244            let gap = gap_dp.as_ref().unwrap();
245            (n_anchor..nseq).map(|i| {
246                let fwd_self = local_align(
247                    &forward[i], &forward[i],
248                    &sc.consweight_matrix, &sc.amino_map, gap, 0.0,
249                ).alignment.score;
250                let rev_self = local_align(
251                    &forward[i], &reverse[i],
252                    &sc.consweight_matrix, &sc.amino_map, gap, 0.0,
253                ).alignment.score;
254                (i, fwd_self - rev_self)
255            }).collect()
256        }
257    };
258    // C uses `qsort` (glibc, unstable) with a `b - a` comparator →
259    // descending. To match C's tie-break behaviour as closely as a
260    // standard library allows, use rust's `sort_unstable_by`
261    // (pdqsort). Rust's stable sort would lock the *original input*
262    // order on ties, while qsort's tie-break is data-dependent.
263    // Neither matches the other bit-exactly on every conceivable
264    // tied input, but unstable matches the spirit better. On the
265    // committed fixtures the contrast key
266    // (forward_self − reverse_self) does not produce ties, so the
267    // choice has no observed effect; the regression test sweep
268    // covering 8 + 5 + 17 + 36 sequences is byte-identical to C
269    // under both stable and unstable here.
270    testable.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
271    contrast_order.extend(testable);
272    let order: Vec<usize> = contrast_order.iter().map(|(i, _)| *i).collect();
273
274    // Step 3: decide orientation for each sequence in contrast order.
275    // Without `--add` the first contrast-order entry is forced to F
276    // (the pivot); with `--add` the first `n_anchor` entries are
277    // forced to F (the existing sequences). Subsequent sequences
278    // compare against every previously-decided sequence and pick
279    // the higher-mean direction.
280    let mut direction = vec![Direction::Forward; nseq];
281    let mut chosen_points: Vec<&[u32]> = Vec::with_capacity(nseq);
282    let mut chosen_seqs: Vec<&[u8]> = Vec::with_capacity(nseq);
283
284    // Seed `chosen_*` with the anchors (or the single pivot in the
285    // no-add case). `pivot_count = max(n_anchor, 1)` matches C's
286    // `iend = 1` fallback when `nadd == 0`.
287    let pivot_count = n_anchor.max(1);
288    for step in 0..pivot_count.min(nseq) {
289        chosen_points.push(&points_fwd[order[step]]);
290        chosen_seqs.push(&forward[order[step]]);
291        // direction[*] is already Forward by default.
292    }
293
294    let reflim = match mode { AdjustMode::Kmer => REFERENCE_LIMIT_KMER,
295                              AdjustMode::Dp   => REFERENCE_LIMIT_DP };
296
297    for step in pivot_count..nseq {
298        let ic = order[step];
299        let iend = step.min(reflim);
300
301        let (res_forward, res_reverse) = match mode {
302            AdjustMode::Kmer => {
303                // Composition tables for forward and reverse candidate
304                // orientations of sequence `ic` (C lines 1014-1019).
305                let table_fwd = composition_table(&points_fwd[ic], TSIZE);
306                let table_rev = composition_table(&points_rev[ic], TSIZE);
307                let mut sum_f: f64 = 0.0;
308                let mut sum_r: f64 = 0.0;
309                for j in 0..iend {
310                    let ref_points = chosen_points[j];
311                    sum_f += common_sextets_p(&table_fwd, ref_points, TSIZE) as f64;
312                    sum_r += common_sextets_p(&table_rev, ref_points, TSIZE) as f64;
313                }
314                (sum_f / iend as f64, sum_r / iend as f64)
315            }
316            AdjustMode::Dp => {
317                // Per-pair local alignment score for both orientations
318                // (C `makedirectionlist.c::directionthread` lines
319                // 637-647 in the `dodp` branch).
320                let sc = scoring.as_ref().unwrap();
321                let gap = gap_dp.as_ref().unwrap();
322                let mut sum_f: f64 = 0.0;
323                let mut sum_r: f64 = 0.0;
324                for j in 0..iend {
325                    let r = chosen_seqs[j];
326                    sum_f += local_align(
327                        &forward[ic], r,
328                        &sc.consweight_matrix, &sc.amino_map, gap, 0.0,
329                    ).alignment.score;
330                    sum_r += local_align(
331                        &reverse[ic], r,
332                        &sc.consweight_matrix, &sc.amino_map, gap, 0.0,
333                    ).alignment.score;
334                }
335                (sum_f / iend as f64, sum_r / iend as f64)
336            }
337        };
338
339        // C `makedirectionlist.c:1234`: strict `>` — ties default to F.
340        if res_reverse > res_forward {
341            direction[ic] = Direction::Reverse;
342            chosen_points.push(&points_rev[ic]);
343            chosen_seqs.push(&reverse[ic]);
344        } else {
345            direction[ic] = Direction::Forward;
346            chosen_points.push(&points_fwd[ic]);
347            chosen_seqs.push(&forward[ic]);
348        }
349    }
350
351    // Step 4: if the original-index-0 sequence ended up Reverse,
352    // flip every direction (C `makedirectionlist.c:1261-1270`).
353    // This ensures the first output sequence is always forward.
354    if direction[0] == Direction::Reverse {
355        for d in direction.iter_mut() {
356            *d = match *d { Direction::Forward => Direction::Reverse, Direction::Reverse => Direction::Forward };
357        }
358    }
359
360    // Step 5: materialise the decisions back into a SequenceSet. For
361    // reversed sequences, swap the data for the reverse-complement
362    // (sreverse on the ORIGINAL non-gappicked data, matching
363    // `setdirection.c:142`) and prefix the name with `_R_`. Forward
364    // sequences keep their original data and name unchanged.
365    let mut adjusted = input.clone();
366    for (i, dir) in direction.iter().enumerate() {
367        if *dir == Direction::Reverse {
368            // setdirection.c operates on the original (possibly
369            // gap-containing) sequence. Mirror that — though our
370            // engine usually receives already-gappicked input, the
371            // call should be safe either way.
372            let rc = reverse_complement(&input.sequences[i].data);
373            adjusted.sequences[i] = Sequence {
374                name: format!("_R_{}", input.sequences[i].name),
375                data: rc,
376            };
377        }
378    }
379    adjusted
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use mafft_types::{Sequence, SeqType};
386
387    fn dna_set(seqs: &[(&str, &str)]) -> SequenceSet {
388        SequenceSet {
389            sequences: seqs.iter().map(|(n, s)| Sequence {
390                name: (*n).to_string(),
391                data: s.as_bytes().to_vec(),
392            }).collect(),
393            seq_type: SeqType::Dna,
394        }
395    }
396
397    #[test]
398    fn reverse_complement_basic() {
399        assert_eq!(reverse_complement(b"ACGT"), b"ACGT");
400        assert_eq!(reverse_complement(b"AAAA"), b"TTTT");
401        assert_eq!(reverse_complement(b"AcGt"), b"aCgT");
402    }
403
404    #[test]
405    fn rna_t_to_u_when_majority_u() {
406        // mostly U input → output should also be U-form (creverse U→A,
407        // then ttou converts T→U on the complement).
408        assert_eq!(reverse_complement(b"uuuu"), b"aaaa");
409        assert_eq!(reverse_complement(b"auug"), b"caau");
410    }
411
412    #[test]
413    fn protein_passthrough() {
414        let set = SequenceSet {
415            sequences: vec![Sequence { name: "p1".into(), data: b"MKLVN".to_vec() }],
416            seq_type: SeqType::Protein,
417        };
418        let adjusted = adjust_direction(&set);
419        assert_eq!(adjusted.sequences[0].data, b"MKLVN");
420        assert_eq!(adjusted.sequences[0].name, "p1");
421    }
422
423    #[test]
424    fn detects_reversed_sequence() {
425        // Build a clearly directional sequence and its reverse-complement.
426        // Need ≥6 unambiguous bases for the 6-mer encoder to see it.
427        let fwd = "atggcaattcgcatggcaattcgcatggcaattcgc";
428        let rc: String = fwd.chars().rev().map(|c| match c {
429            'a' => 't', 'c' => 'g', 'g' => 'c', 't' => 'a',
430            _ => c,
431        }).collect();
432        // Two more forward copies + one reverse → adjust should flip the reverse one.
433        let set = dna_set(&[
434            ("s1", fwd),
435            ("s2", fwd),
436            ("s3_rc", &rc),
437        ]);
438        let adjusted = adjust_direction(&set);
439        // s3 should be reverse-complemented back to forward, name prefixed with _R_.
440        assert!(adjusted.sequences[2].name.starts_with("_R_"),
441            "expected _R_ prefix, got {}", adjusted.sequences[2].name);
442        assert_eq!(adjusted.sequences[2].data, fwd.as_bytes(),
443            "reversed sequence should now equal forward");
444        // s1, s2 unchanged.
445        assert_eq!(adjusted.sequences[0].name, "s1");
446        assert_eq!(adjusted.sequences[1].name, "s2");
447    }
448}