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}