mafft_core/engine.rs
1/// High-level MAFFT alignment engine.
2
3use rayon::prelude::*;
4
5use mafft_io::read_fasta;
6use mafft_scoring::{build_context, build_context_with_kimura};
7use mafft_tree::{DistanceMatrix, musclesupg, ktuple_distance, scoring_matrix_distance};
8use mafft_tree::parttree_split::{build_parttree_topology};
9use mafft_tree::parttree_pivot::PtSeqKind;
10use mafft_align::{build_local_homology_table, GapModel};
11use mafft_types::{ScoringModel, SequenceSet, LocalHomologyTable};
12
13use crate::progressive::MultipleAlignment;
14use crate::refinement::{iterative_refine, RefinementParams};
15use crate::add::{add_sequences, add_sequences_keeplength};
16
17/// Alignment mode (strategy).
18#[derive(Debug, Clone)]
19pub enum AlignmentMode {
20 /// FFT-NS-2: fast progressive (default).
21 FftNs2,
22 /// FFT-NS-i: progressive + limited iterative refinement.
23 FftNsi { iterations: usize },
24 /// G-INS-i: global alignment + iterative refinement.
25 GInsi { iterations: usize },
26 /// L-INS-i: local alignment + iterative refinement.
27 LInsi { iterations: usize },
28 /// E-INS-i: generalized affine + iterative refinement.
29 EInsi { iterations: usize },
30 /// Q-INS-i: RNA alignment with McCaskill base-pair probabilities.
31 QInsi { iterations: usize },
32 /// X-INS-i: RNA alignment with CONTRAfold structure predictions.
33 XInsi { iterations: usize },
34}
35
36impl Default for AlignmentMode {
37 fn default() -> Self {
38 Self::FftNs2
39 }
40}
41
42/// Matrix shift applied when rebuilding the refinement-tree distances the
43/// way C's `dndpre` does, for the modes that have no `pairlocalalign` step
44/// (FFT-NS-i and friends).
45///
46/// `scripts/mafft` does NOT pass `-h` to the `dndpre` invocation that writes
47/// `hat2` for `dvtditr`, so C's `constants()` falls back to its per-alphabet
48/// DEFAULT `poffset` — and the two alphabets do not share one:
49///
50/// | alphabet | default `poffset` | `offset = (int)(600/1000 * poffset + 0.5)` | shift |
51/// |------------|----------------------------|--------------------------------------------|-------|
52/// | nucleotide | `DEFAULTOFS_N = -369` (`DNA.h:3`) | -220 | 220 |
53/// | protein | `DEFAULTOFS_B = -123` (`blosum.c:3`) | -73 | 73 |
54///
55/// The DP matrix is shifted by `-offset`. Using the protein 73 for DNA made
56/// the refinement distances differ from C's `hat2` outright — on
57/// `mtb_cds_120x1400` the leaf pair (0,58) came out 0.307 against C's 0.253 —
58/// which reordered UPGMA merges (steps 10 and 11 swapped), and a swapped
59/// merge changes the group on 131 of 237 refinement branches. DNA FFT-NS-i
60/// byte-parity with C over BAliBASE `bali2dna` went 67/141 → 132/141 when
61/// this was corrected.
62pub fn dndpre_offset_shift(is_nucleotide: bool) -> i32 {
63 if is_nucleotide { 220 } else { 73 }
64}
65
66/// Scale factors turning the pair-phase `ppenalty`-style integers
67/// (`lgop * 1000`, …) into DP units: `(gap_scale, offset_scale)`.
68///
69/// Mirrors C `constants.c`. Nucleotide (`:316-322`):
70/// `penalty = (int)( 3 * 600.0/1000.0 * ppenalty + 0.5 )` and likewise
71/// `penalty_ex` / `penalty_OP` / `penalty_dist`, but
72/// `offset = (int)( 1 * 600.0/1000.0 * poffset + 0.5 )`. Protein
73/// (`:672-677`): `600.0/1000.0` for all of them. The `3 *` on nucleotide
74/// gap penalties is load-bearing — without it DNA pairwise gaps cost a
75/// third of C's and L-INS-i / G-INS-i / E-INS-i diverge — so it lives in
76/// one named place with a test pinning it.
77pub fn pair_penalty_scales(is_nucleotide: bool) -> (f64, f64) {
78 if is_nucleotide {
79 (3.0 * 600.0 / 1000.0, 1.0 * 600.0 / 1000.0)
80 } else {
81 (600.0 / 1000.0, 600.0 / 1000.0)
82 }
83}
84
85/// The main MAFFT alignment engine.
86#[derive(Debug, Clone)]
87pub struct MafftEngine {
88 pub mode: AlignmentMode,
89 pub scoring_model: ScoringModel,
90 /// Number of guide tree rebuilds. C's FFT-NS-2 default is 2.
91 pub retree: usize,
92 /// Gap opening penalty override (positive float, e.g. 1.53 → internal -1530).
93 /// None = use default for the scoring model.
94 pub gap_open: Option<f64>,
95 /// Offset/extension penalty override (positive float, e.g. 0.123 → internal -123).
96 /// None = use default.
97 pub gap_offset: Option<f64>,
98 /// Gap extension penalty override (`--exp`). User passes a positive
99 /// float (e.g. `--exp 0.1`); C negates internally (`gexp = -1.0 * arg`)
100 /// and then `constants()` scales by `(int)(scale * gexp + 0.5)`
101 /// (`scale = 600/1000` for protein, `3*600/1000` for DNA). When
102 /// `Some`, overrides `scoring.gap.extend` in the same pattern as
103 /// `gap_offset` overrides `scoring.gap.offset`. Default `None` keeps
104 /// the model default (0 for protein/DNA).
105 pub gap_extend: Option<f64>,
106 /// Per-class pairwise gap params for L-INS-i (`--lop` / `--lep` /
107 /// `--lexp`). Override the hardcoded `lgop=-2.00 / laof=0.100 /
108 /// lexp=-0.100` C defaults applied in the local pairwise alignment
109 /// stage. Each `None` = use the C default.
110 pub pair_lop: Option<f64>,
111 pub pair_lep: Option<f64>,
112 pub pair_lexp: Option<f64>,
113 /// Per-class pairwise gap params for E-INS-i generalized affine
114 /// (`--gop` / `--gep` / `--gexp`). Override the C defaults
115 /// `pggop=-1.53 / pgaof=0.10 / pgexp=-0.00`. Each `None` = use the
116 /// C default.
117 pub pair_gop: Option<f64>,
118 pub pair_gep: Option<f64>,
119 pub pair_gexp: Option<f64>,
120 /// `--shiftpenalty` factor for `--allowshift`. C's `spfactor`
121 /// multiplies the gap-open penalty to derive the per-cell shift
122 /// cost: `penalty_shift = (int)(spfactor * penalty)`. Default 2.0
123 /// (the C `--allowshift` baseline). `None` keeps the default.
124 pub shift_penalty_factor: Option<f64>,
125 /// `--minimumweight` floor applied to per-sequence weights in the
126 /// intergroup-score accumulation. Threaded into
127 /// `RefinementParams.minimum_weight`. `None` keeps the C default
128 /// (`0.00001`).
129 pub minimum_weight: Option<f64>,
130 /// `--nwildcard`: fill the DNA scoring matrix's `'n'` row
131 /// with `round(0.25 * self_score)` per residue, matching C's
132 /// `constants.c::nscore`. When false (default and `--nzero`),
133 /// the N row stays at the build-time defaults (effectively
134 /// zero for the unscored entries). DNA-only — protein inputs
135 /// silently bypass.
136 pub nwildcard: bool,
137 /// `--skipiterate F` threshold — mirrors C's `dvtditr -E
138 /// $fixthreshold` → `autosubalignment = F`. When F exceeds the
139 /// max distance-from-tip in the guide tree, refinement is
140 /// skipped entirely (matches C's `generatesubalignmentstable`
141 /// returning 1, which prints the "WARNING: Iterative refinement
142 /// was not done" diagnostic and exits). For small F values, the
143 /// port (R-3, closed 2026-06-03) generates sub-alignment
144 /// clusters via `mafft_tree::generate_subalignments_table` and
145 /// sets per-(step, side) skip flags in
146 /// `RefinementParams::skip_branches`, mirroring C's
147 /// `dvtditr.c:997-1006` `includemember && !samemember` gate.
148 pub skipiterate: Option<f64>,
149 /// `--bestfirst` refinement strategy. Default false (BAATARI2,
150 /// matches C MAFFT's default). When true, refinement evaluates
151 /// every branch against the same baseline alignment per
152 /// iteration, picks the one with the largest gain, applies,
153 /// repeats — mirroring C's `parallelizationstrategy = BESTFIRST`.
154 pub bestfirst: bool,
155 /// `--thread N`. C selects a different refinement implementation on
156 /// `nthread > 0` (`tditeration.c:1433`): `athread` walks the tree in
157 /// a fixed order and converges/stops by different rules than the
158 /// single-threaded loop — see `RefinementParams::per_cycle_convergence`.
159 /// `0` (the default, and what C's script passes for both no `--thread`
160 /// and `--thread 0`) selects the single-threaded rules.
161 pub nthread: usize,
162 /// `--oneiteration` "one-vs-others" refinement (C's
163 /// `disttbfast -r` → `dooneiteration` in
164 /// `mafft-upstream/core/disttbfast.c:2217`). Runs once after the
165 /// progressive merge and before regular refinement. ONLY
166 /// triggered in the disttbfast-path modes (FFT-NS-2, FFT-NS-i);
167 /// L/G/E-INS-i bypass it because `scripts/mafft:2673` only
168 /// passes `-r` to `disttbfast`, never to `tbfast` or `dvtditr`.
169 pub oneiteration: bool,
170 /// `--pileup`: build a comb-tree guide via
171 /// [`mafft_tree::Topology::pileup_chain`] instead of UPGMA,
172 /// then run progressive merge once with no refinement (C
173 /// strategy name "Pileup-NS-1", `scripts/mafft:2169`). Skips
174 /// distance computation entirely. Forces `retree = 1` and
175 /// disables `--maxiterate` refinement.
176 pub pileup: bool,
177 /// Tree-linkage method for UPGMA cluster joining. Mirrors C's
178 /// `tbfast -X $sueff` (`scripts/mafft:264,419,422,427`). Default
179 /// = `Mix { sueff: 0.1 }` (C default). `--averagelinkage` →
180 /// `Mix { sueff: 1.0 }` ≡ `Average`; `--minimumlinkage` → `Mix
181 /// { sueff: 0.0 }` ≡ `Minimum`; `--mixedlinkage F` → `Mix { sueff:
182 /// F }`. C's `--youngestlinkage` is a separate algorithm
183 /// (memory-saving k-mer tree builder with on-demand cluster
184 /// distance recompute); rust wires it via `self.memsavetree`,
185 /// which uses the same algorithm family; the dedicated
186 /// `--youngestlinkage` port (`mafft_tree::youngestlinkage_tree`)
187 /// is byte-identical to C (see `TODO.md` R-8).
188 pub cluster_method: mafft_tree::ClusterMethod,
189 /// Disable FFT: force pure DP for all alignment steps.
190 pub nofft: bool,
191 /// Enable long-range gap shift penalty (--allowshift). In MAFFT 7.526 the
192 /// warp DP itself is dead code (`defs.c:54 trywarp = 0` and never set);
193 /// the actual `--allowshift` effect is to set `unalign_level = 0.8` which
194 /// triggers per-step `makedynamicmtx` (`disttbfast.c:2304`). Kept as a
195 /// boolean for CLI symmetry; only `unalign_level > 0` has runtime effect.
196 pub allowshift: bool,
197 /// Per-step substitution-score offset = (distfromtip - unalign_level) * 600
198 /// (clamped at 0). Mirrors C `specificityconsideration` + `dist2offset`
199 /// + `makedynamicmtx`. 0 = disabled, 0.8 = `--allowshift` default.
200 pub unalign_level: f64,
201 /// Kimura R parameter for DNA distance model (--kimura).
202 pub kimura_r: Option<i32>,
203 /// Use PartTree for guide tree construction (--parttree).
204 pub parttree: bool,
205 /// Use DP-based PartTree (--dpparttree).
206 pub dpparttree: bool,
207 /// Group size for PartTree partitioning (--groupsize).
208 pub groupsize: Option<usize>,
209 /// Reorder output sequences in guide-tree DFS order (--reorder). Default
210 /// is input order (--inputorder), matching C MAFFT 7.526.
211 pub reorder_output: bool,
212 /// Use a user-supplied guide tree (`--treein FILE`). Format matches C
213 /// MAFFT's `_guidetree`: nseq-1 lines of `im jm len0 len1` (1-indexed,
214 /// im < jm), as produced by `newick2mafft.rb`. When `Some`, distance
215 /// computation and tree building are skipped — the loaded tree is
216 /// used for every progressive pass (mirrors C `tbfast.c:2072-2078`).
217 pub treein_path: Option<std::path::PathBuf>,
218 /// Use the memory-saving guide-tree algorithm (`--memsavetree`).
219 /// Mirrors C MAFFT `compacttree_memsaveselectable` with `howcompact=2`
220 /// (`mltaln9.c:5491`) — k-mer-based distances computed on the fly with
221 /// no full distance matrix. Enabled by `--auto` for the 100k+ bracket.
222 pub memsavetree: bool,
223 /// `--youngestlinkage` — same family as memsavetree but with per-step
224 /// recomputation of cluster distances after each join. C MAFFT
225 /// `mltaln9.c::compacttree_memsaveselectable(howcompact=2, memsave=1)`.
226 pub youngestlinkage: bool,
227 /// `--leavegappyregion` / `--legacygappenalty` — disable the
228 /// gap-aware DP reweighting (`legacygapcost = 1`,
229 /// `Salignmm.c:1604-1610`). Restores pre-7.110 behaviour where
230 /// gappy columns are scored as if fully nongap.
231 pub legacy_gap_cost: bool,
232 /// Seed local-homology table (`--seed FILE` constraints). Mirrors
233 /// C MAFFT's `hat3.seed` produced by `multi2hat3s` — pairwise
234 /// `korh = 'k'` regions between seed sequences with `opt`
235 /// pre-multiplied by `tsuyosa = user_nseq² * 100`. The table is
236 /// sized to the full (seeds + user input) `nseq`. When `Some`,
237 /// the engine folds these entries into its pairwise homology
238 /// table (or uses them directly for non-INS-i modes) and forces
239 /// `iterate ≥ 2` so the refinement step picks them up
240 /// (`scripts/mafft:1911-1923`).
241 pub seed_homology: Option<LocalHomologyTable>,
242 /// `--memsave` Hirschberg DP routing. When true and the non-FFT
243 /// progressive merge would call `profile_align`, route through
244 /// `mafft_align::msalignmm` instead (linear-space DP — mirrors C
245 /// MAFFT's `MSalignmm` in `tbfast.c:1159-1161` under `alg='M'`).
246 /// For inputs that fit in memory the alignment is the same as
247 /// `profile_align`; only memory usage differs.
248 pub memsave_dp: bool,
249 /// `--c-compat` opt-in: replicate C MAFFT's `Salignmm.c::A__align`
250 /// static-TLS memoization (`reuseprofiles` / `cpmx_calc_add`) so
251 /// tied-DP-cell choices match C bit-for-bit at the cost of
252 /// carrying per-thread cross-call state. Default false (stateless,
253 /// pure progressive engine). Enable to reproduce C's output on
254 /// inputs where the §B.2 / BALIBASE-corpus residual divergences
255 /// matter for downstream byte-equality requirements. See the
256 /// BB20027 deep-dive in `balibase_parity_run.md` for the diagnosis
257 /// (historical: every BALIBASE fixture is now byte-identical without it).
258 pub c_compat: bool,
259}
260
261impl Default for MafftEngine {
262 fn default() -> Self {
263 Self {
264 mode: AlignmentMode::FftNs2,
265 scoring_model: ScoringModel::Blosum(62),
266 retree: 2,
267 gap_open: None,
268 gap_offset: None,
269 gap_extend: None,
270 pair_lop: None,
271 pair_lep: None,
272 pair_lexp: None,
273 pair_gop: None,
274 pair_gep: None,
275 pair_gexp: None,
276 shift_penalty_factor: None,
277 minimum_weight: None,
278 skipiterate: None,
279 bestfirst: false,
280 nthread: 0,
281 oneiteration: false,
282 nwildcard: false,
283 pileup: false,
284 cluster_method: mafft_tree::ClusterMethod::default(),
285 nofft: false,
286 allowshift: false,
287 unalign_level: 0.0,
288 kimura_r: None,
289 parttree: false,
290 dpparttree: false,
291 groupsize: None,
292 reorder_output: false,
293 treein_path: None,
294 memsavetree: false,
295 youngestlinkage: false,
296 legacy_gap_cost: false,
297 seed_homology: None,
298 memsave_dp: false,
299 c_compat: false,
300 }
301 }
302}
303
304impl MafftEngine {
305 pub fn new(mode: AlignmentMode) -> Self {
306 Self { mode, scoring_model: ScoringModel::Blosum(62), retree: 2,
307 gap_open: None, gap_offset: None, gap_extend: None,
308 pair_lop: None, pair_lep: None, pair_lexp: None,
309 pair_gop: None, pair_gep: None, pair_gexp: None,
310 shift_penalty_factor: None, minimum_weight: None,
311 skipiterate: None, bestfirst: false, nthread: 0, oneiteration: false, nwildcard: false,
312 pileup: false,
313 cluster_method: mafft_tree::ClusterMethod::default(),
314 nofft: false, allowshift: false, unalign_level: 0.0,
315 kimura_r: None, parttree: false, dpparttree: false,
316 groupsize: None, reorder_output: false, treein_path: None,
317 memsavetree: false, youngestlinkage: false, legacy_gap_cost: false,
318 seed_homology: None, memsave_dp: false, c_compat: false }
319 }
320
321 /// Enable `--c-compat`: replicate C MAFFT's static-TLS cpmx
322 /// memoization so tied-DP-cell choices match C bit-for-bit.
323 pub fn with_c_compat(mut self, c_compat: bool) -> Self {
324 self.c_compat = c_compat;
325 self
326 }
327
328 /// Set the number of guide tree rebuilds.
329 pub fn with_retree(mut self, retree: usize) -> Self {
330 self.retree = retree;
331 self
332 }
333
334 /// Set gap opening penalty (positive float, e.g. 1.53).
335 pub fn with_gap_open(mut self, op: f64) -> Self {
336 self.gap_open = Some(op);
337 self
338 }
339
340 /// Set offset/extension penalty (positive float, e.g. 0.123).
341 pub fn with_gap_offset(mut self, ep: f64) -> Self {
342 self.gap_offset = Some(ep);
343 self
344 }
345
346 /// Use PartTree for guide tree (--parttree).
347 pub fn with_parttree(mut self, parttree: bool) -> Self {
348 self.parttree = parttree;
349 self
350 }
351
352 /// Use DP-based PartTree (--dpparttree).
353 pub fn with_dpparttree(mut self, dpparttree: bool) -> Self {
354 self.dpparttree = dpparttree;
355 self
356 }
357
358 /// Set group size for PartTree (--groupsize).
359 pub fn with_groupsize(mut self, groupsize: usize) -> Self {
360 self.groupsize = Some(groupsize);
361 self
362 }
363
364 /// Set Kimura R parameter for DNA distance model (default 2).
365 pub fn with_kimura(mut self, kimura_r: i32) -> Self {
366 self.kimura_r = Some(kimura_r);
367 self
368 }
369
370 /// Enable long-range gap shift penalty (CLI symmetry only — see field
371 /// docstring; only `unalign_level > 0` has runtime effect).
372 pub fn with_allowshift(mut self, allowshift: bool) -> Self {
373 self.allowshift = allowshift;
374 self
375 }
376
377 /// Set per-step dynamic-matrix offset (`specificityconsideration`).
378 /// Disabled at 0.0; `--allowshift` defaults to 0.8.
379 pub fn with_unalign_level(mut self, level: f64) -> Self {
380 self.unalign_level = level;
381 self
382 }
383
384 /// Disable FFT: force pure DP for all alignment steps.
385 pub fn with_nofft(mut self, nofft: bool) -> Self {
386 self.nofft = nofft;
387 self
388 }
389
390 /// Emit output sequences in guide-tree DFS order (`--reorder`). When
391 /// `false` (default), output stays in input order (`--inputorder`).
392 pub fn with_reorder(mut self, reorder: bool) -> Self {
393 self.reorder_output = reorder;
394 self
395 }
396
397 /// Set scoring model (e.g. BLOSUM with specific number).
398 pub fn with_scoring_model(mut self, model: ScoringModel) -> Self {
399 self.scoring_model = model;
400 self
401 }
402
403 /// Align a set of sequences.
404 pub fn align(&self, input: &SequenceSet) -> MultipleAlignment {
405 let seq_type = input.seq_type;
406 let scoring_model = if seq_type.is_nucleotide() {
407 ScoringModel::Dna
408 } else {
409 self.scoring_model
410 };
411
412 let mut scoring = if let Some(kr) = self.kimura_r {
413 build_context_with_kimura(scoring_model, seq_type, kr)
414 } else {
415 build_context(scoring_model, seq_type)
416 };
417
418 // `--nwildcard` (and the implicit case from `unalignlevel != 0`
419 // — see `scripts/mafft:1437` setting `nmodel=" -: "`). Fills
420 // the DNA scoring matrix's `'n'` row with 25%-self-score
421 // values. DNA-only — protein inputs no-op. Apply BEFORE any
422 // gap penalty overrides so they still take effect on the
423 // residue submatrix.
424 let want_nwildcard = self.nwildcard || self.unalign_level > 0.0;
425 if want_nwildcard {
426 mafft_scoring::apply_nwildcard(&mut scoring);
427 }
428
429 // Apply gap penalty overrides if set.
430 // C convention: --op 1.53 means ppenalty = -1530 (multiply by -1000).
431 // After scaling: penalty = (int)(600/1000 * ppenalty + 0.5).
432 if let Some(op) = self.gap_open {
433 let ppenalty = -(op * 1000.0) as i32;
434 let scale = if seq_type.is_nucleotide() { 3.0 * 600.0 / 1000.0 } else { 600.0 / 1000.0 };
435 scoring.gap.open = (scale * ppenalty as f64 + 0.5) as i32;
436 }
437 if let Some(ep) = self.gap_offset {
438 let poffset = -(ep * 1000.0) as i32;
439 let scale = if seq_type.is_nucleotide() { 1.0 * 600.0 / 1000.0 } else { 600.0 / 1000.0 };
440 let new_offset = (scale * poffset as f64 + 0.5) as i32;
441 // C's constants() bakes the offset into the scoring matrix during
442 // construction: `n_distmp[i][j] -= offset`. Our build_context()
443 // builds the matrix with offset=0 (matching C's default aof=0 from
444 // the shell script), NOT with gap_params.offset. So the "old"
445 // offset baked into the matrix is 0, regardless of what
446 // scoring.gap.offset says.
447 let matrix_offset = 0i32;
448 let delta = new_offset - matrix_offset;
449 if delta != 0 {
450 let nscored = scoring.nscoredalphabets;
451 for i in 0..nscored {
452 for j in 0..nscored {
453 scoring.substitution_matrix[i][j] -= delta;
454 scoring.consweight_matrix[i][j] = scoring.substitution_matrix[i][j] as f64;
455 scoring.fft_matrix[i][j] = scoring.substitution_matrix[i][j] + new_offset;
456 }
457 }
458 }
459 scoring.gap.offset = new_offset;
460 }
461 // `--exp` (gap extension penalty). C: `gexp = -1.0 * arg` then
462 // `penalty_ex = (int)(scale * gexp + 0.5)` where `scale =
463 // 600/1000` (protein) or `3*600/1000` (DNA). Same shape as the
464 // `gap_open` override above.
465 if let Some(exp) = self.gap_extend {
466 let pgexp = -(exp * 1000.0) as i32;
467 let scale = if seq_type.is_nucleotide() { 3.0 * 600.0 / 1000.0 } else { 600.0 / 1000.0 };
468 scoring.gap.extend = (scale * pgexp as f64 + 0.5) as i32;
469 }
470
471 let nseq = input.nseq();
472 let quiet_mode = false;
473 // C `disttbfast.c:4450` (`tbfast.c:3320`, `pairlocalalign.c:3372`)
474 // calls `gappick0(bseq[i], seq[i])` for every sequence before
475 // anything is aligned — the engine works on RESIDUE-ONLY
476 // sequences regardless of whether the input FASTA had gaps.
477 // Without this, feeding a previously-aligned FASTA (gaps in
478 // input) produces a different alignment than C because the rust
479 // progressive sees the gapped form (closes R-6: 16+1 adversarial
480 // fixture diverged by 369 lines on combined_17.fa, byte-identical
481 // on the residue-only c17_ungapped.fa).
482 //
483 // Only `-` is a gap here. `gappick0` (`mltaln9.c:10537`) leaves
484 // `.` alone: for protein it is a legal residue (`amino_n['.']` =
485 // 23, `blosum.c:12`), for nucleotide it is illegal and C exits
486 // before reaching this point (`seqcheck`, `mltaln9.c:60`).
487 let sequences: Vec<Vec<u8>> = input.sequences.iter().map(|s| {
488 s.data.iter().copied().filter(|&c| c != b'-').collect()
489 }).collect();
490 let names: Vec<String> = input.sequences.iter().map(|s| s.name.clone()).collect();
491
492 let use_fft = !self.nofft && matches!(
493 self.mode,
494 AlignmentMode::FftNs2 | AlignmentMode::FftNsi { .. }
495 );
496
497 // Step 1: Initial guide tree
498 // For PartTree mode, route through the C-equivalent splittbfast
499 // pipeline (`crates/mafft-tree/src/parttree_split.rs`). Both
500 // `--parttree` and `--dpparttree` route through it — the C
501 // distinction (`partdist="ktuples"` vs `partdist="localalign"`,
502 // `scripts/mafft:392/395`) is a distance-metric variation
503 // inside C's `splittbfast`; our Rust pipeline currently uses
504 // k-tuple distance for both. The 36-seq sample is below
505 // PartTree's recursion threshold so both modes byte-identical
506 // C MAFFT 7.526. A true DP-based distance for `--dpparttree`
507 // larger-input runs is not yet ported (would slot into
508 // `parttree_dist.rs`).
509 let use_parttree = self.parttree || self.dpparttree;
510 let parttree_topo = if use_parttree {
511 let kind = if scoring.seq_type.is_nucleotide() {
512 PtSeqKind::Dna
513 } else {
514 PtSeqKind::Protein
515 };
516 let picksize = 50;
517 Some(build_parttree_topology(&sequences, kind, picksize))
518 } else {
519 None
520 };
521
522 // For L-INS-i / E-INS-i, MAFFT's `pairlocalalign` (then `tbfast`) replaces
523 // the 6-mer initial distance with a distance derived from all-vs-all
524 // pairwise local alignments. We piggyback on `build_local_homology_table`,
525 // which already runs the same pairwise alignments to populate the
526 // homology constraint table — we keep both outputs (distance + table).
527 let pair_kind = match self.mode {
528 AlignmentMode::LInsi { .. } => Some(mafft_align::PairAligner::Local),
529 AlignmentMode::GInsi { .. } => Some(mafft_align::PairAligner::Global),
530 AlignmentMode::EInsi { .. } => {
531 Some(mafft_align::PairAligner::GeneralizedAffine)
532 }
533 _ => None,
534 };
535 let mut pairwise_for_constraints = if let Some(aligner) = pair_kind {
536 let seq_refs: Vec<&[u8]> = sequences.iter()
537 .map(|s| s.as_slice()).collect();
538 // C's `pairlocalalign` uses pairwise-specific gap penalties,
539 // NOT the progressive ones (`scripts/mafft:91-92,201-203`).
540 // For L-INS-i (`-L`): lgop=-2.00, lexp=-0.100, laof=0.100.
541 // For G-INS-i (`-A`): pgop=$pggop, pgexp=$pggexp, pgaof=$pgaof.
542 // Defaults match L-INS-i values for protein
543 // (`scripts/mafft:91-92`). Same numbers below.
544 // C's argument parser (`pairlocalalign.c:1671-1675`) does
545 // ppenalty = (int)( atof(arg) * 1000 - 0.5 )
546 // which is C-style truncation toward zero (`as i32` in Rust).
547 // For `-f -2.00` this gives -2000 (not -2001). Then
548 // `constants.c:1014-1016` does
549 // penalty = (int)( 600/1000 * ppenalty + 0.5 )
550 // which is round-half-up for positive and round-half-up-toward-
551 // zero for negative — also `as i32` truncation in Rust because
552 // for negative numbers like -1199.5, `(int)` gives -1199 not
553 // -1200.
554 //
555 // For `-2.00 / -0.100 / 0.100`: C gets penalty = -1199,
556 // penalty_ex = -59, offset = 59. Round-naively in Rust we'd
557 // get -1200 / -60 / 60 — off by 1, which propagates through
558 // `iscore` and yields a 1-per-residue gap in `opt` (~0.01
559 // off vs C across all pairs).
560 let cc_int = |x: f64, mul: f64| -> i32 {
561 ((x * mul) - 0.5) as i32
562 };
563 let cc_scale = |ppen: i32, scale: f64| -> i32 {
564 ((scale * ppen as f64) + 0.5) as i32
565 };
566 // L-INS-i / G-INS-i defaults (script:91-92,201-203).
567 // E-INS-i overrides (`scripts/mafft:1940-1948`): when
568 // distance="localgenaf" (and `oldgenafparam != 1`), the script
569 // resets `lexp="0.0"` and `laof="0.0"` so the regular gap-extend
570 // and matrix-offset are zeroed out, leaving only the gen-affine
571 // skip-gap (LGOP) as the long-range penalty.
572 let is_einsi = matches!(self.mode, AlignmentMode::EInsi { .. });
573 // `scripts/mafft:1469-1473`: when `unalignlevel > 0` zero
574 // `lexp=laof=pgexp=pgaof=0` for the pair phase.
575 let unalign_active = self.unalign_level > 0.0;
576 // Pair-phase gap defaults. C scripts/mafft assigns these per
577 // mode. Both L-INS-i and E-INS-i pairwise alignment use
578 // `lgop`/`lexp`/`laof` (the "L-INS" gap params); `--lop` /
579 // `--lep` / `--lexp` override. The `pggop`/`pgaof`/`pgexp`
580 // family (set by `--gop`/`--gep`/`--gexp` in C) is reserved
581 // for the X-INS-i / Q-INS-i RNA pipelines which we don't
582 // exercise — surfacing them at the CLI but they are
583 // currently inert for protein/DNA workflows. For E-INS-i,
584 // `lexp` and `laof` are forced to 0 (the generalized-affine
585 // skip-gap cost — `lgop_op = LGOP = -6.00` below — covers
586 // long-range gaps instead).
587 let lgop: f64 = self.pair_lop.unwrap_or(-2.00);
588 let lexp: f64 = if is_einsi || unalign_active {
589 self.pair_lexp.unwrap_or(0.0)
590 } else {
591 self.pair_lexp.unwrap_or(-0.100)
592 };
593 let laof: f64 = if is_einsi || unalign_active {
594 self.pair_lep.unwrap_or(0.0)
595 } else {
596 self.pair_lep.unwrap_or(0.100)
597 };
598 // E-INS-i extras (`scripts/mafft:198-199`):
599 // LGOP=-6.00 → ppenalty_OP (skip-gap open).
600 // LEXP= 0.0 → ppenalty_EX (skip-gap extend, unused; C
601 // comments out the extension increments).
602 let lgop_op: f64 = -6.00;
603 // C scales the pair-phase penalties differently for nucleotide
604 // and protein (`constants.c:316-322` vs `:672-677`):
605 // nucleotide: penalty/penalty_ex/penalty_OP = 3 * 600/1000 * pp
606 // offset = 1 * 600/1000 * po
607 // protein: everything = 600/1000 * pp
608 // The gap penalties carry the `3 *`; the offset does not (C
609 // writes the `1 *` out explicitly beside the `3 *`s). Applying
610 // the protein factor to DNA made pairwise gaps a third of C's
611 // cost, so L-INS-i / G-INS-i / E-INS-i opened gaps C refused.
612 // Same idiom as the progressive-phase penalties above.
613 let (gap_scale, offset_scale) = pair_penalty_scales(seq_type.is_nucleotide());
614 let p_open = cc_int(lgop, 1000.0);
615 let p_ext = cc_int(lexp, 1000.0);
616 let p_offset = cc_int(laof, 1000.0);
617 let p_op = cc_int(lgop_op, 1000.0);
618 let mut pair_gap = GapModel::new(
619 cc_scale(p_open, gap_scale) as f64,
620 cc_scale(p_ext, gap_scale) as f64,
621 );
622 // C `constants.c:277-278`: `if (penalty_shift_factor < 10) trywarp = 1`.
623 // With `--allowshift`, `spfactor = 2.0` (< 10) → warp DP fires.
624 // `penalty_shift = (int)(penalty_shift_factor * penalty)`
625 // (`constants.c:318`). For pair phase: penalty = -1199, sp = 2.0,
626 // so penalty_shift = -2398.
627 if self.unalign_level > 0.0 {
628 let spfactor = self.shift_penalty_factor.unwrap_or(2.0);
629 let penalty_shift = (spfactor * pair_gap.open) as i32 as f64;
630 pair_gap.shift = Some(penalty_shift);
631 }
632 let pair_op = cc_scale(p_op, gap_scale) as f64;
633 let pair_offset_int: i32 = cc_scale(p_offset, offset_scale);
634 let nscored = scoring.nscoredalphabets;
635 // The DP layer takes f64 matrices (post §9c migration). Build
636 // the shifted matrix from `consweight_matrix` (= f64 view of
637 // `substitution_matrix`) and subtract the pair offset there.
638 let pair_offset_f64 = pair_offset_int as f64;
639 let mut shifted: Vec<Vec<f64>> = scoring.consweight_matrix.clone();
640 for i in 0..nscored {
641 for j in 0..nscored {
642 shifted[i][j] -= pair_offset_f64;
643 }
644 }
645 // C's `L__align11` sets `localthr = -offset + scoreoffset * 600`
646 // (Lalign11.c:248-249). With `scoreoffset = 0` and the
647 // `offset = (int)(0.6 * poffset + 0.5)` computed above
648 // (= `pair_offset_int`), C uses `localthr = -pair_offset_int`.
649 // Our `local_align` computes `localthr = -score_offset * 600`,
650 // so to reach `localthr = -pair_offset_int` we pass
651 // `score_offset = pair_offset_int / 600`.
652 let score_offset_for_local = pair_offset_int as f64 / 600.0;
653 let (table, dist) = mafft_align::build_homology_table_with_unalign(
654 &seq_refs,
655 &shifted,
656 &scoring.amino_map,
657 &pair_gap,
658 score_offset_for_local,
659 aligner,
660 pair_op,
661 self.unalign_level,
662 );
663 // For L-INS-i, tbfast computes pairwise alignments in-memory
664 // via `callpairlocalalign=1`. The `iscore` distance matrix
665 // is passed straight to `fixed_musclesupg_double_realloc_…`
666 // without a hat2 file round-trip (the script does not invoke
667 // a separate pairlocalalign + hat2 read), so we keep full
668 // double precision here. The 3-decimal hat2 rounding only
669 // applies on paths that genuinely write/read `hat2` (e.g.
670 // FFT-NS-i + dndpre).
671 Some((table, DistanceMatrix::from_full(&dist)))
672 } else {
673 None
674 };
675
676 let mut dm = if use_parttree {
677 // Skip full distance matrix — PartTree builds tree directly
678 DistanceMatrix::new(nseq)
679 } else if let Some((_, ref pre_dm)) = pairwise_for_constraints {
680 pre_dm.clone()
681 } else {
682 compute_distance_matrix_from_seqs(&sequences)
683 };
684
685 // Load user-supplied guide tree early (`--treein`), so the
686 // importance-recomputation below uses the same topology C does
687 // (`tbfast.c:2967 counteff_simple_double_nostatic_memsave( njob, topol, len, dep, eff )`
688 // where `topol`/`len` come from `loadtree` when `treein=1`).
689 let user_topo: Option<mafft_tree::Topology> = self.treein_path.as_ref().map(|path| {
690 mafft_tree::parse_mafft_tree(path, nseq).unwrap_or_else(|e| {
691 eprintln!("--treein: {e}");
692 std::process::exit(1);
693 })
694 });
695
696 // `--seed`: fold seed-derived `hat3.seed` entries into the
697 // pairwise homology table BEFORE `recompute_importance`, so the
698 // position-vote pass weighs seed regions together with pairwise
699 // ones. Mirrors C MAFFT's `cat hat3.seed hat3 > hat3`
700 // (`scripts/mafft:2523-2540`) — tbfast then reads the merged
701 // file before calling `calcimportance_half`.
702 //
703 // When the mode doesn't run pairwise homology (FFT-NS-i with
704 // `--seed`), promote the seed table to be the constraint table
705 // outright; the pairwise distance matrix is left alone (FFT-NS-i
706 // uses ktuple distances by default — same as without `--seed`).
707 if let Some(ref seed_lh) = self.seed_homology {
708 if let Some((ref mut table, _)) = pairwise_for_constraints {
709 mafft_align::merge_homology_tables(table, seed_lh);
710 }
711 }
712
713 // C's `tbfast` calls `calcimportance_half` (mltaln9.c:11756) AFTER
714 // the initial tree to replace each region's provisional importance
715 // with `mean(position-vote support over region) * region.opt`,
716 // then symmetrize across (i,j)/(j,i). `region.opt` is stored in
717 // C's post-`tbfast.c:2202` scale (`isumscore / sumoverlap`), so
718 // the impmtx contributions match C's numerically.
719 if pairwise_for_constraints.is_some() && !use_parttree {
720 // C computes the constraint `importance` TWICE, with DIFFERENT
721 // trees, and the two phases must be mirrored separately:
722 //
723 // 1. tbfast (progressive) calls `calcimportance` using weights
724 // from the FULL-PRECISION in-memory `iscore` UPGMA tree
725 // (`tbfast.c:2926` builds it from the un-rounded distance
726 // matrix). This importance drives the progressive merge.
727 // 2. dvtditr (refinement) RE-reads the original hat3 and calls
728 // `calcimportance` again, this time with weights from the
729 // 3-decimal `hat2` tree (`readhat2_pointer`, dvtditr.c:753).
730 //
731 // This block is phase (1): use the full-precision `dm` tree.
732 // Phase (2) is mirrored just before `iterative_refine` below,
733 // where `local_hom`'s importance is recomputed from the rounded
734 // refinement tree. Using the rounded tree here instead would
735 // regress E-INS BB40004 (progressive merge diverges, 588 lines);
736 // using the full tree for refinement would regress BB50001
737 // (244 lines). Verified both directions empirically.
738 //
739 // With `--treein`, C uses the loaded user tree for both phases.
740 let initial_topo = user_topo.clone()
741 .unwrap_or_else(|| musclesupg(&dm, self.cluster_method));
742 let weights = mafft_tree::sequence_weights(&initial_topo);
743 let seq_refs: Vec<&[u8]> = sequences.iter()
744 .map(|s| s.as_slice()).collect();
745 if let Some((ref mut table, _)) = pairwise_for_constraints {
746 mafft_align::recompute_importance(table, &seq_refs, &weights);
747 }
748 }
749
750 // Step 2: Build guide tree and progressive align, repeating `retree` times.
751 // Each iteration after the first computes distances from the ALIGNMENT
752 // (not the raw sequences), producing a better tree.
753 //
754 // C's `scripts/mafft:142-156` sets `defaultcycle=1` for L/G/E/Q/X-INS-i
755 // (vs `defaultcycle=2` for FFT-NS-2 and FFT-NS-i). The script then
756 // applies two post-`--retree` rewrites that override the user value:
757 //
758 // 1. `scripts/mafft:1840-1842` clamps `cycle = min(cycle, 3)` for
759 // ALL distance modes — `--retree 5` becomes 3 in C.
760 // 2. `scripts/mafft:1934-1936` forces `cycle = 1` in the
761 // `distance ∈ {local, global, localgenaf, globalgenaf, scarna}`
762 // branch (= L/G/E/Q/X-INS-i) regardless of `--retree`. So
763 // `--retree 3 --localpair` runs with cycle=1 in C, not 3.
764 //
765 // Mirror both: cap at 3 for FFT-NS-*, force 1 for INS-i regardless
766 // of the user value. Without this, `--retree N --localpair` (N > 1)
767 // silently runs extra passes vs C and diverges by hundreds of lines
768 // (898 lines on the 36-seq sample with `--retree 3 --localpair`).
769 let retree = if matches!(
770 self.mode,
771 AlignmentMode::LInsi { .. }
772 | AlignmentMode::GInsi { .. }
773 | AlignmentMode::EInsi { .. }
774 | AlignmentMode::QInsi { .. }
775 | AlignmentMode::XInsi { .. }
776 ) {
777 1
778 } else if self.pileup {
779 // `--pileup` is "Pileup-NS-1" — single progressive pass,
780 // no second tree-rebuild (`scripts/mafft:2169` strategy
781 // name; C disttbfast forces `cycle = 1` for the pileup
782 // guidetree variant at line 1991).
783 1
784 } else {
785 self.retree.clamp(1, 3)
786 };
787 let mut msa = MultipleAlignment {
788 sequences: sequences.clone(),
789 names: names.clone(),
790 score: 0.0,
791 step_trace: Vec::new(), guide_tree: None, first_pass_sequences: None, distance_matrix: None,
792 };
793 let mut accumulated_trace = Vec::new();
794 let penalty_dist = scoring.gap.open;
795 // Final progressive guide tree — used for `--reorder` output ordering
796 // (mirrors C `tbfast.c:2928` writing the order file from the
797 // post-UPGMA topology, BEFORE any iterative refinement).
798 let mut final_progressive_topo: Option<mafft_tree::Topology> = None;
799 // Intermediate alignment after pass 0 of the retree loop. C's
800 // `--parttree` script runs `splittbfast` TWICE: CALL 1 produces
801 // `pre_1` (this is what we want to capture here) and CALL 2 reads
802 // `pre_1` as `orialn` for its `naivepairscore11`-based distance
803 // computation. Without this we'd feed CALL 2 the FINAL alignment
804 // (`pre_2`) and the scores would diverge.
805 let mut first_pass_msa: Option<Vec<Vec<u8>>> = None;
806
807 // `user_topo` was loaded above (before recompute_importance) so the
808 // LH table weights and the progressive merge tree are derived from
809 // the SAME topology — matching C's `tbfast.c:2072` (loadtree) +
810 // `tbfast.c:2967` (counteff_simple from loaded topol) sequencing.
811
812 // `--memsavetree`: build the guide tree with the C MAFFT
813 // `compacttree_memsaveselectable` algorithm. C uses k-mer-based
814 // `distcompact` in disttbfast (pass 0, raw sequences) and switches
815 // to MSA-based `distcompact_msa` in tbfast (pass 1+, aligned
816 // sequences). Mirrors `disttbfast.c:4018` then
817 // `tbfast.c:2538`.
818 //
819 // Pass 0 uses k-mer; pass 1+ uses MSA. The MSA tree is rebuilt
820 // INSIDE the retree loop from `msa.sequences` after each pass.
821 let memsavetree_kmer_topo: Option<mafft_tree::Topology> = if self.memsavetree || self.youngestlinkage {
822 let seq_refs: Vec<&[u8]> = sequences.iter()
823 .map(|s| s.as_slice()).collect();
824 let is_dna = scoring.seq_type.is_nucleotide();
825 if self.youngestlinkage {
826 Some(mafft_tree::memsavetree::youngestlinkage_tree(&seq_refs, is_dna))
827 } else {
828 Some(mafft_tree::memsavetree::memsavetree(&seq_refs, is_dna))
829 }
830 } else {
831 None
832 };
833
834 for pass in 0..retree {
835 let topo = if let Some(ref t) = user_topo {
836 t.clone()
837 } else if self.pileup {
838 // `--pileup`: skip the distance matrix entirely and
839 // build a comb-tree directly from the input order.
840 // C `mltaln9.c::createchain` with `shuffle=0`.
841 mafft_tree::Topology::pileup_chain(nseq)
842 } else if self.memsavetree || self.youngestlinkage {
843 if pass == 0 {
844 memsavetree_kmer_topo.clone().expect("memsavetree topology must be cached")
845 } else {
846 // MSA-based rebuild from the prior pass's alignment.
847 // For --youngestlinkage, use the compacttree=4 MSA
848 // variant (`youngestlinkage_tree_msa`); for
849 // --memsavetree, use the compacttree=3 MSA variant
850 // (`memsavetree_msa`).
851 let aligned_refs: Vec<&[u8]> = msa.sequences.iter()
852 .map(|s| s.as_slice()).collect();
853 if self.youngestlinkage {
854 mafft_tree::memsavetree::youngestlinkage_tree_msa(
855 &aligned_refs,
856 &scoring.consweight_matrix,
857 &scoring.amino_map,
858 scoring.gap.open as f64,
859 )
860 } else {
861 mafft_tree::memsavetree::memsavetree_msa(
862 &aligned_refs,
863 &scoring.consweight_matrix,
864 &scoring.amino_map,
865 scoring.gap.open as f64,
866 )
867 }
868 }
869 } else if pass == 0 && use_parttree {
870 parttree_topo.clone().unwrap()
871 } else {
872 musclesupg(&dm, self.cluster_method)
873 };
874
875 // C always progresses from raw input on each retree pass.
876 let input_seqs = sequences.clone();
877
878 // Shift penalty: penalty_shift = penalty_shift_factor * penalty
879 // (`constants.c:318`). Default factor = 100 (trywarp = 0). With
880 // `--allowshift`, `scripts/mafft:1428` sets `spfactor=2.00`, which
881 // triggers `trywarp = 1` (constants.c:277-278: `if (factor < 10)`)
882 // and gives `penalty_shift = 2.0 * penalty`. The previous "0.8"
883 // here was confused with `unalignlevel = 0.8` — different knob.
884 let shift = if self.allowshift {
885 let spfactor = self.shift_penalty_factor.unwrap_or(2.0);
886 Some(spfactor * scoring.gap.open as f64)
887 } else {
888 None
889 };
890
891 // For L-INS-i / E-INS-i, thread the local-homology table through
892 // the progressive merges so they pick up the same per-cell
893 // importance bonuses the refinement DP already uses. C's tbfast
894 // does this via Falign_localhom (FFT) or partA__align (per
895 // segment). We currently only handle the non-FFT branch in
896 // `progressive_align_with_constraints` — that's the path
897 // L-INS-i takes since the engine sets `use_fft = false` for
898 // any non-FftNs2/FftNsi mode.
899 let progress_constraints = pairwise_for_constraints
900 .as_ref().map(|(t, _)| t);
901 // C's `tbfast` is invoked with different `outgap` settings per
902 // mode (`scripts/mafft:2584,2593,2601`): G-INS-i omits the
903 // `$termgapopt = -O` flag so `outgap = 1` (head/tail gap
904 // penalized). L-INS-i and E-INS-i pass `-O` so `outgap = 0`.
905 // The progressive A__align/profile_align_imp call propagates
906 // this as `headgp = tailgp = outgap`.
907 // C `outgap=1` (terminal gaps penalized) is the global default
908 // (`splittbfast.c:560`, `disttbfast.c:185`) and is overridden to
909 // 0 by the `-O` flag (`scripts/mafft:291 termgapopt=" -O "`).
910 // The mafft script passes `-O` to disttbfast/tbfast for most
911 // modes but withholds it for:
912 // - `--globalpair` (G-INS-i / G-INS-1) — `scripts/mafft:2584`
913 // vs L-INS-i/E-INS-i which include termgapopt.
914 // - `--parttree` / `--dpparttree` — `scripts/mafft:2655` does
915 // not include `$termgapopt` in the splittbfast call.
916 let penalize_term_gaps = matches!(self.mode, AlignmentMode::GInsi { .. })
917 || use_parttree;
918 // C `splittbfast.c:6` `#define WEIGHT 0` makes `--parttree` use
919 // `fastconjuction_noweight` (uniform per-cluster weights) for
920 // its internal `pairalign`. We mirror that by passing a
921 // uniform-1.0 weight vector when `use_parttree`. `--pileup`
922 // also disables weighting (`disttbfast.c:3962` sets
923 // `weight = 0; tbrweight = 0` → `eff[i] = 1.0` for all i at
924 // `disttbfast.c:4131`). All other modes derive weights from
925 // the guide tree's branch lengths.
926 let weights_override: Option<Vec<f64>> = if use_parttree || self.pileup {
927 Some(vec![1.0; sequences.len()])
928 } else {
929 None
930 };
931 // Disable the cached profile blend (`blend_profiles_exact`).
932 // C's `createcpmxresult` (Salignmm.c:608) omits the eff*1.0 gap
933 // contribution at gap-insertion positions ("tsukawanai" comment
934 // at line 624). C also only uses the cache in pass 0 (`treebase`)
935 // and forces fresh `cpmx_calc_new` in pass 1+ (`dooneiteration`
936 // — disttbfast.c:2288-2289 sets `cpmxchild0/1 = NULL`). Rust's
937 // blend mirrors C's createcpmxresult math-faithfully, but
938 // matching when the cache fires across all BB tests is delicate
939 // — disabling the blend entirely (always `cpmx_calc_new`) gives
940 // byte-identical output across the BB20018 / BB40046 set without
941 // perf impact at typical alignment sizes.
942 msa = crate::progressive::progressive_align_full_c_compat_ex(
943 &input_seqs, &names, &topo, &scoring, use_fft, shift,
944 progress_constraints, penalize_term_gaps,
945 weights_override.as_deref(), self.unalign_level,
946 self.legacy_gap_cost, self.memsave_dp, self.c_compat,
947 false,
948 );
949 accumulated_trace.extend(msa.step_trace.iter().copied());
950 final_progressive_topo = Some(topo.clone());
951 if pass == 0 && first_pass_msa.is_none() {
952 first_pass_msa = Some(msa.sequences.clone());
953 }
954
955 // For the next retree pass, recompute distances from the now-aligned
956 // sequences (matching C's disttbfast behavior in the second iteration
957 // of `iguidetree`). The refinement-tree distance matrix is built
958 // separately further down with a different (offset-shifted) matrix
959 // mirroring dndpre's invocation.
960 if pass + 1 < retree {
961 dm = compute_distance_matrix_scoring(
962 &msa.sequences, &scoring.substitution_matrix,
963 &scoring.amino_map, penalty_dist,
964 );
965 }
966 }
967 msa.step_trace = accumulated_trace;
968
969 // Step 3: Build local homology table (for constrained modes)
970 let uses_constraints = matches!(
971 self.mode,
972 AlignmentMode::LInsi { .. }
973 | AlignmentMode::EInsi { .. }
974 | AlignmentMode::GInsi { .. }
975 );
976 let uses_rna_constraints = matches!(
977 self.mode,
978 AlignmentMode::QInsi { .. } | AlignmentMode::XInsi { .. }
979 );
980 let mut local_hom = if uses_rna_constraints {
981 // RNA modes: compute base-pair probabilities using external tools,
982 // then use them as constraints for iterative refinement.
983 let bpp_result = match &self.mode {
984 AlignmentMode::QInsi { .. } => {
985 crate::external::compute_bpp_mccaskill(
986 &sequences,
987 )
988 }
989 AlignmentMode::XInsi { .. } => {
990 crate::external::compute_bpp_contrafold(
991 &sequences,
992 )
993 }
994 _ => unreachable!(),
995 };
996 match bpp_result {
997 Ok(bpp_tables) => {
998 if !quiet_mode {
999 eprintln!("RNA structure: computed BPP for {} sequences", bpp_tables.len());
1000 }
1001 // For now, use standard local homology as fallback.
1002 // Full BPP→constraint integration would convert base-pair
1003 // probabilities into pairwise constraints here.
1004 let seq_refs: Vec<&[u8]> = sequences.iter().map(|s| s.as_slice()).collect();
1005 let gap = GapModel::new(scoring.gap.open as f64, scoring.gap.extend as f64);
1006 let (table, _dist) = build_local_homology_table(
1007 &seq_refs,
1008 &scoring.consweight_matrix,
1009 &scoring.amino_map,
1010 &gap,
1011 0.0,
1012 );
1013 Some(table)
1014 }
1015 Err(e) => {
1016 eprintln!("{e}");
1017 std::process::exit(1);
1018 }
1019 }
1020 } else if uses_constraints {
1021 // Reuse the table built up-front for the initial distance matrix.
1022 // Keep the initial pairwise distance matrix alongside for the
1023 // refinement tree (mirrors C's `dvtditr` reading the `hat2` file
1024 // written by initial pairlocalalign instead of recomputing from
1025 // the progressive alignment).
1026 pairwise_for_constraints.as_ref().map(|(t, _)| t.clone())
1027 } else if let Some(ref seed_lh) = self.seed_homology {
1028 // `--seed` with a non-INS-i mode (e.g. FFT-NS-i + `--seed`):
1029 // no pairwise homology was built, but seed entries still
1030 // need to drive refinement. Run `recompute_importance` on
1031 // the seed-only table here (using a sequence-weight vector
1032 // derived from the final progressive guide tree, mirroring
1033 // `tbfast.c:2967` calling `calcimportance` after the post-
1034 // progressive tree is in hand).
1035 let mut table = seed_lh.clone();
1036 let seq_refs: Vec<&[u8]> = msa.sequences.iter()
1037 .map(|s| s.as_slice()).collect();
1038 let weights = final_progressive_topo.as_ref()
1039 .map(mafft_tree::sequence_weights)
1040 .unwrap_or_else(|| vec![1.0; nseq]);
1041 mafft_align::recompute_importance(&mut table, &seq_refs, &weights);
1042 Some(table)
1043 } else {
1044 None
1045 };
1046 // Stash the initial pairwise distance matrix for the refinement tree
1047 // (modes that ran pairlocalalign, where C's dvtditr reads `hat2`).
1048 let initial_pairwise_dm: Option<DistanceMatrix> = pairwise_for_constraints
1049 .as_ref()
1050 .map(|(_, dm)| dm.clone());
1051
1052 // `--oneiteration`: C's `disttbfast -r` → `dooneiteration`
1053 // (`disttbfast.c:2217-2538`). Runs AFTER progressive merge,
1054 // BEFORE regular refinement. Gated on the disttbfast-path
1055 // modes ONLY (FFT-NS-2, FFT-NS-i) — `scripts/mafft:2673`
1056 // passes `-r` only to `disttbfast`, not to `tbfast`/`dvtditr`.
1057 // L/G/E-INS-i use pairlocalalign+tbfast, so their pipeline
1058 // never sees `-r` even when `--oneiteration` is given (C
1059 // confirmed: `--localpair --oneiteration` ≡ `--localpair`
1060 // alone, byte-identical).
1061 if self.oneiteration
1062 && matches!(self.mode,
1063 AlignmentMode::FftNs2 | AlignmentMode::FftNsi { .. })
1064 {
1065 // `final_progressive_topo` is the guide tree from the
1066 // last `progressive_align_full_c_compat_ex` pass
1067 // (set at line 802 above). Always Some at this point
1068 // for the disttbfast-path modes (FFT-NS-2/i).
1069 let oneiter_topo = final_progressive_topo
1070 .as_ref()
1071 .expect("FFT-NS-2/i path always sets final_progressive_topo")
1072 .clone();
1073 let refine_shift = if self.allowshift {
1074 let spfactor = self.shift_penalty_factor.unwrap_or(2.0);
1075 Some((spfactor * scoring.gap.open as f64) as i32 as f64)
1076 } else {
1077 None
1078 };
1079 let one_iter_params = RefinementParams {
1080 max_iterations: 0, // unused by one_vs_others_refine
1081 use_fft: true,
1082 legacy_gap_cost: self.legacy_gap_cost,
1083 shift: refine_shift,
1084 unalign_level: self.unalign_level,
1085 minimum_weight: self.minimum_weight.unwrap_or(0.00001),
1086 ..Default::default()
1087 };
1088 crate::refinement::one_vs_others_refine(
1089 &mut msa, &oneiter_topo, &scoring, &one_iter_params,
1090 );
1091 }
1092
1093 // Step 4: Iterative refinement (if mode requires it)
1094 match &self.mode {
1095 AlignmentMode::FftNs2 => {}
1096 AlignmentMode::FftNsi { iterations }
1097 | AlignmentMode::GInsi { iterations }
1098 | AlignmentMode::LInsi { iterations }
1099 | AlignmentMode::EInsi { iterations }
1100 | AlignmentMode::QInsi { iterations }
1101 | AlignmentMode::XInsi { iterations } => {
1102 // C's mafft script does NOT pass `-h` to dndpre (the second
1103 // invocation that writes hat2 for dvtditr). dndpre therefore
1104 // uses the BLOSUM62 default `poffset = -123` → offset = -73,
1105 // shifting every cell of the scoring matrix by +73 relative
1106 // to the offset=0 matrix disttbfast and dvtditr use for DP.
1107 // The refinement tree dvtditr builds reads this hat2, so the
1108 // distance matrix we feed `musclesupg` here must use the same
1109 // shifted matrix and operate on the FINAL progressive
1110 // alignment (`msa.sequences`).
1111 // For modes that ran an initial pairlocalalign step (L-INS-i,
1112 // G-INS-i, E-INS-i, ...), C's `dvtditr` reads `hat2` written
1113 // by tbfast's pairlocalalign — initial pairwise distances at
1114 // 3-decimal precision. We mirror that exactly.
1115 //
1116 // For modes without pairlocalalign (FFT-NS-i, distance="ktuples"),
1117 // C's script invokes `dndpre` between tbfast and dvtditr to
1118 // recompute distances from the progressive alignment with
1119 // `dndpre`'s DEFAULT poffset shift. We mirror that path here.
1120 let dm = if let Some(ref initial_dm) = initial_pairwise_dm {
1121 // Mimic hat2 file's `%.3f` rounding so musclesupg sees the
1122 // same distances dvtditr sees.
1123 let n = initial_dm.nseq;
1124 let mut rounded = DistanceMatrix::new(n);
1125 for i in 0..n {
1126 for j in (i + 1)..n {
1127 let d = (initial_dm.get(i, j) * 1000.0).round() / 1000.0;
1128 rounded.set(i, j, d);
1129 }
1130 }
1131 rounded
1132 } else {
1133 let dndpre_offset_shift = dndpre_offset_shift(seq_type.is_nucleotide());
1134 let mut shifted_matrix: Vec<Vec<i32>> = scoring.substitution_matrix
1135 .iter()
1136 .map(|row| row.iter().map(|&v| v + dndpre_offset_shift).collect())
1137 .collect();
1138 let nscored = scoring.nscoredalphabets;
1139 for i in 0..shifted_matrix.len() {
1140 for j in 0..shifted_matrix[i].len() {
1141 if i >= nscored || j >= nscored {
1142 shifted_matrix[i][j] = 0;
1143 }
1144 }
1145 }
1146 let penalty_dist = scoring.gap.open;
1147 let raw = compute_distance_matrix_scoring(
1148 &msa.sequences, &shifted_matrix,
1149 &scoring.amino_map, penalty_dist,
1150 );
1151 // C's dndpre writes hat2 with `%.3f` precision
1152 // (`io.c::write_hat2`). dvtditr then reads back these
1153 // 3-decimal values. Round here so musclesupg sees the
1154 // same distances dvtditr sees — needed for `--treeout`
1155 // branch-length parity with C.
1156 let n = raw.nseq;
1157 let mut rounded = DistanceMatrix::new(n);
1158 for i in 0..n {
1159 for j in (i + 1)..n {
1160 let d = (raw.get(i, j) * 1000.0).round() / 1000.0;
1161 rounded.set(i, j, d);
1162 }
1163 }
1164 rounded
1165 };
1166 // With `--treein`, C's dvtditr also loads the user tree
1167 // (`dvtditr.c:766-768` if(intree) ...
1168 // veryfastsupg_double_loadtree). Override the distance-based
1169 // rebuild so refinement runs against the same user-supplied
1170 // topology as the progressive pass.
1171 let topo = if let Some(ref t) = user_topo {
1172 t.clone()
1173 } else {
1174 musclesupg(&dm, self.cluster_method)
1175 };
1176 // C's `--treeout` writes the refinement tree built inside
1177 // `dvtditr` (not the progressive tbfast tree). Override
1178 // `final_progressive_topo` so the Newick we emit for
1179 // FFT-NS-i / *-INS-i modes matches what C writes.
1180 final_progressive_topo = Some(topo.clone());
1181 // C's mafft script (scripts/mafft:1512) sets `iteratelimit=254`
1182 // for BESTFIRST/BAATARI0, else `iteratelimit=16`, then caps
1183 // `iterate` to that. Mirror that strategy-dependent cap so
1184 // `--bestfirst --maxiterate 100` actually runs 100 best-move
1185 // iterations (C width 713) instead of stopping at 16 (rust
1186 // width 725 before this fix).
1187 let iterate_limit = if self.bestfirst { 254 } else { 16 };
1188 // `dvtditr.c:704-708`: `if( njob == 2 ) { weight = 0; niter = 1; }`
1189 // — a pair is refined exactly once, unweighted (uniform
1190 // weights are what `BranchWeights` already returns at 2).
1191 let iterate_limit = if nseq == 2 { 1 } else { iterate_limit };
1192 let capped_iterations = (*iterations).min(iterate_limit);
1193 // C's mafft script always passes -F (use_fft=1) to dvtditr
1194 // for refinement (scripts/mafft line 1531: rnaoptit=" -F "),
1195 // regardless of whether progressive alignment used FFT.
1196 // `--allowshift`: C's dvtditr gets `-Q 2.0` →
1197 // `penalty_shift_factor = 2.0` → `trywarp = 1`, with
1198 // `penalty_shift = (int)(penalty_shift_factor * penalty)`
1199 // (constants.c:318). The refinement `penalty` is the scaled
1200 // gap-open (`scoring.gap.open`). Pass it so the refinement
1201 // profile DP enables the warp/shift state (already ported in
1202 // `profile_align_imp_with_boundary` via `gap.shift`).
1203 let refine_shift = if self.allowshift {
1204 let spfactor = self.shift_penalty_factor.unwrap_or(2.0);
1205 Some((spfactor * scoring.gap.open as f64) as i32 as f64)
1206 } else {
1207 None
1208 };
1209 let params = RefinementParams {
1210 max_iterations: capped_iterations,
1211 // C picks the refinement implementation on `nthread > 0`
1212 // (`tditeration.c:1433`) and the two converge differently.
1213 per_cycle_convergence: self.nthread > 0,
1214 use_fft: true,
1215 legacy_gap_cost: self.legacy_gap_cost,
1216 shift: refine_shift,
1217 // Multi-distance-class DP gates on `unalign_level > 0`, not
1218 // on `--allowshift`: C `scripts/mafft:1436` enables the
1219 // `-s #` (specificityconsideration) path whenever
1220 // `unalignlevel != 0.0`, whether set via `--allowshift`
1221 // (→ 0.8) or `--unalignlevel #` directly. Only the warp/
1222 // shift DP (`refine_shift` above) is allowshift-specific
1223 // (it needs `spfactor < 10`, which `--unalignlevel` alone
1224 // does not set).
1225 unalign_level: self.unalign_level,
1226 minimum_weight: self.minimum_weight.unwrap_or(0.00001),
1227 bestfirst: self.bestfirst,
1228 ..Default::default()
1229 };
1230 // C `dvtditr.c:882` switches to segmented refinement (split
1231 // the alignment at high-conservation anchors, refine each
1232 // segment independently) whenever `constraint == 0` and
1233 // `bunkatsu != 0` — i.e. FFT-NS-i but not the *-INS-i modes,
1234 // which set `constraint=2` and stay single-segment.
1235 // `--seed` adds local homology constraints (constraint != 0),
1236 // so seeded FFT-NS-i must also stay on the single-segment
1237 // refinement path — gate on `local_hom.is_none()`.
1238 // Phase (2) of the C importance computation (see the long
1239 // comment at the progressive-phase `recompute_importance`
1240 // above): dvtditr RE-computes `importance` from the 3-decimal
1241 // `hat2` refinement tree before iterating. `topo` here is that
1242 // rounded tree (built from the hat2-rounded `dm` / dndpre
1243 // distances just above, or the user tree). Recompute
1244 // `local_hom`'s importance from it so refinement sees the
1245 // same constraint weights C's dvtditr does. The progressive
1246 // merge already consumed the full-precision-tree importance.
1247 // Skipped under `--treein` (user_topo) since both phases use
1248 // the same loaded tree, and for RNA modes (importance there
1249 // is not distance-tree-derived).
1250 if local_hom.is_some() && pairwise_for_constraints.is_some()
1251 && user_topo.is_none() && !uses_rna_constraints
1252 {
1253 let weights = mafft_tree::sequence_weights(&topo);
1254 let seq_refs: Vec<&[u8]> = msa.sequences.iter()
1255 .map(|s| s.as_slice()).collect();
1256 if let Some(ref mut lh) = local_hom {
1257 mafft_align::recompute_importance(lh, &seq_refs, &weights);
1258 }
1259 }
1260 // `--skipiterate F`: C's `dvtditr -E $fixthreshold` →
1261 // `autosubalignment = F`. The function
1262 // `generatesubalignmentstable` (mltaln9.c:15330-15407)
1263 // walks the tree and identifies sub-alignment clusters
1264 // whose internal merges are all ≤ F. Two outcomes:
1265 // - Whole tree below threshold → skip refinement
1266 // entirely (`distfromtip[0] <= threshold`, returns 1).
1267 // - Otherwise → sub-alignments are recorded, and
1268 // `dvtditr.c:997-1006` marks topology branches that
1269 // are STRICT SUBSETS of any sub-alignment as
1270 // `skipthisbranch[step][side]=1` so refinement
1271 // skips them.
1272 let (skip_refinement, skip_branches_vec) = if let Some(f) = self.skipiterate {
1273 let (sub_alignments, all_below) =
1274 mafft_tree::generate_subalignments_table(&topo, f);
1275 if all_below {
1276 eprintln!(
1277 "\n#################################################################\n\
1278 # WARNING: Iterative refinment was not done because you gave a\n\
1279 # large --skipiterate value ({f:.3}).\n\
1280 #################################################################\n"
1281 );
1282 (true, Vec::new())
1283 } else {
1284 // Build the per-(step, side) skip mask from
1285 // sub-alignments. A branch is skipped iff its
1286 // subtree (step.left or step.right) is a STRICT
1287 // SUBSET of any sub-alignment cluster (C's
1288 // `includemember && !samemember` at
1289 // `dvtditr.c:997-1006`).
1290 let nsteps = topo.steps.len();
1291 let sub_sets: Vec<std::collections::BTreeSet<usize>> =
1292 sub_alignments.iter().map(|s| s.iter().copied().collect()).collect();
1293 let skip_branches: Vec<(bool, bool)> = topo.steps.iter().map(|step| {
1294 let l: std::collections::BTreeSet<usize> = step.left.iter().copied().collect();
1295 let r: std::collections::BTreeSet<usize> = step.right.iter().copied().collect();
1296 let mut skip_l = false;
1297 let mut skip_r = false;
1298 for s in &sub_sets {
1299 if !skip_l && l.is_subset(s) && l != *s { skip_l = true; }
1300 if !skip_r && r.is_subset(s) && r != *s { skip_r = true; }
1301 if skip_l && skip_r { break; }
1302 }
1303 (skip_l, skip_r)
1304 }).collect();
1305 let _ = (nsteps, sub_alignments);
1306 (false, skip_branches)
1307 }
1308 } else {
1309 (false, Vec::new())
1310 };
1311 let params = RefinementParams {
1312 skip_branches: skip_branches_vec,
1313 ..params
1314 };
1315 // `--skipiterate F` small-F: skip-branches honored
1316 // only by the standard `iterative_refine` BAATARI2
1317 // loop. C's dvtditr does this regardless of the
1318 // segmented mode; for parity we route the small-F
1319 // case through the un-segmented refinement.
1320 let has_skip_branches = !params.skip_branches.is_empty();
1321 let use_segmented = matches!(self.mode, AlignmentMode::FftNsi { .. })
1322 && local_hom.is_none()
1323 && !has_skip_branches;
1324 if !skip_refinement {
1325 if params.bestfirst {
1326 // `--bestfirst` (C `parallelizationstrategy=BESTFIRST`):
1327 // pick best-gain branch per iteration. Bypasses both
1328 // the BAATARI2 walk and the FFT-segmented variant.
1329 crate::refinement::bestfirst_refine(
1330 &mut msa, &topo, &scoring, ¶ms, local_hom.as_ref(),
1331 );
1332 } else if use_segmented {
1333 crate::refinement::segmented_iterative_refine(
1334 &mut msa, &topo, &scoring, ¶ms, local_hom.as_ref(),
1335 );
1336 } else {
1337 iterative_refine(
1338 &mut msa, &topo, &scoring, ¶ms, local_hom.as_ref(),
1339 );
1340 }
1341 }
1342 }
1343 }
1344
1345 // `--reorder`: permute output to the C-equivalent reorder ordering.
1346 //
1347 // - Non-PartTree: tree-DFS over the final progressive guide tree
1348 // (`tbfast.c:2928` calls `topolorderz` on the post-UPGMA topology).
1349 // - PartTree (`--parttree` / `--dpparttree`): C runs `splittbfast`
1350 // TWICE (`scripts/mafft:2655` and `:2681`). CALL 1 uses raw 6-mer
1351 // distances; CALL 2 passes `-Z` (`fromaln=1`) and recomputes
1352 // distances via `naivepairscore11` on the aligned sequences. The
1353 // final output order is the COMPOSITION:
1354 // `final_order[k] = call1_order[call2_order[k]]`
1355 // We mirror both passes to reach byte-identity.
1356 if self.reorder_output {
1357 let order: Option<Vec<usize>> = if use_parttree {
1358 let kind = if scoring.seq_type.is_nucleotide() {
1359 PtSeqKind::Dna
1360 } else {
1361 PtSeqKind::Protein
1362 };
1363 // CALL 1: parttree pivot pipeline on raw sequences.
1364 let call1_order = mafft_tree::parttree_split::compute_parttree_order(
1365 &sequences, kind, 50,
1366 );
1367 // Reorder the FIRST-PASS aligned MSA into CALL 1's order so
1368 // CALL 2 sees `pre_1` (C's intermediate alignment), not the
1369 // final `pre_2`. Without using `first_pass_msa` here, our
1370 // CALL 2 distances would diverge from C's because the two
1371 // passes produce subtly different alignments.
1372 let source_msa: &Vec<Vec<u8>> = first_pass_msa
1373 .as_ref().unwrap_or(&msa.sequences);
1374 let aligned_reordered: Vec<Vec<u8>> = call1_order
1375 .iter().map(|&i| source_msa[i].clone()).collect();
1376 // CALL 2: parttree pivot pipeline with `fromaln=1` scoring
1377 // on the reordered aligned MSA. Uses the progressive-phase
1378 // substitution matrix and gap penalty (matches C's `penalty`
1379 // global set by `constants()`).
1380 let call2_order = mafft_tree::parttree_split::compute_parttree_order_fromaln(
1381 &aligned_reordered,
1382 &scoring.consweight_matrix,
1383 &scoring.amino_map,
1384 scoring.gap.open as f64,
1385 );
1386 // Compose: final_order[k] = call1_order[call2_order[k]].
1387 Some(call2_order.iter().map(|&k| call1_order[k]).collect())
1388 } else {
1389 final_progressive_topo.as_ref().map(|t| t.dfs_order())
1390 };
1391 if let Some(order) = order {
1392 if order.len() == msa.sequences.len() {
1393 msa.sequences = order.iter().map(|&i| msa.sequences[i].clone()).collect();
1394 msa.names = order.iter().map(|&i| msa.names[i].clone()).collect();
1395 }
1396 }
1397 }
1398
1399 // Expose the final progressive guide tree to callers (used for
1400 // `--treeout` Newick serialization by the CLI binary).
1401 msa.guide_tree = final_progressive_topo;
1402 // Expose the first-pass alignment too — `--parttree --treeout`
1403 // and `--parttree --reorder` both need C MAFFT's `pre_1` to
1404 // reproduce CALL 2's tree / order generation.
1405 msa.first_pass_sequences = first_pass_msa;
1406 // Expose the (post-musclesupg) distance matrix used by the
1407 // progressive merge. `--distout` writes this to `<input>.hat2`,
1408 // and `--scoreout` derives the unweighted SP score from it.
1409 // Empty / None for paths that didn't compute a full dm
1410 // (PartTree, `--treein` with a user tree, etc.).
1411 if dm.nseq == nseq && dm.nseq > 0 {
1412 msa.distance_matrix = Some(dm.clone());
1413 }
1414
1415 msa
1416 }
1417
1418 /// Add new sequences to an existing alignment.
1419 ///
1420 /// `existing_input` is the already-aligned MSA (FASTA with gaps).
1421 /// `new_input` contains the new unaligned sequences to add.
1422 /// `keeplength` if true, preserves the existing alignment's column structure.
1423 pub fn add_to_alignment(
1424 &self,
1425 existing_input: &SequenceSet,
1426 new_input: &SequenceSet,
1427 keeplength: bool,
1428 ) -> MultipleAlignment {
1429 let seq_type = existing_input.seq_type;
1430 let scoring_model = if seq_type.is_nucleotide() {
1431 ScoringModel::Dna
1432 } else {
1433 self.scoring_model
1434 };
1435
1436 let mut scoring = build_context(scoring_model, seq_type);
1437
1438 if let Some(op) = self.gap_open {
1439 let ppenalty = -(op * 1000.0) as i32;
1440 let scale = if seq_type.is_nucleotide() { 3.0 * 600.0 / 1000.0 } else { 600.0 / 1000.0 };
1441 scoring.gap.open = (scale * ppenalty as f64 + 0.5) as i32;
1442 }
1443 if let Some(ep) = self.gap_offset {
1444 let poffset = -(ep * 1000.0) as i32;
1445 let scale = if seq_type.is_nucleotide() { 1.0 * 600.0 / 1000.0 } else { 600.0 / 1000.0 };
1446 let new_offset = (scale * poffset as f64 + 0.5) as i32;
1447 let matrix_offset = 0i32;
1448 let delta = new_offset - matrix_offset;
1449 if delta != 0 {
1450 let nscored = scoring.nscoredalphabets;
1451 for i in 0..nscored {
1452 for j in 0..nscored {
1453 scoring.substitution_matrix[i][j] -= delta;
1454 scoring.consweight_matrix[i][j] = scoring.substitution_matrix[i][j] as f64;
1455 scoring.fft_matrix[i][j] = scoring.substitution_matrix[i][j] + new_offset;
1456 }
1457 }
1458 }
1459 scoring.gap.offset = new_offset;
1460 }
1461
1462 let use_fft = !self.nofft && matches!(
1463 self.mode,
1464 AlignmentMode::FftNs2 | AlignmentMode::FftNsi { .. }
1465 );
1466
1467 let existing = MultipleAlignment {
1468 sequences: existing_input.sequences.iter().map(|s| s.data.clone()).collect(),
1469 names: existing_input.sequences.iter().map(|s| s.name.clone()).collect(),
1470 score: 0.0,
1471 step_trace: Vec::new(), guide_tree: None, first_pass_sequences: None, distance_matrix: None,
1472 };
1473
1474 let new_sequences: Vec<Vec<u8>> = new_input.sequences.iter().map(|s| s.data.clone()).collect();
1475 let new_names: Vec<String> = new_input.sequences.iter().map(|s| s.name.clone()).collect();
1476
1477 if keeplength {
1478 add_sequences_keeplength(&existing, &new_sequences, &new_names, &scoring, use_fft)
1479 } else {
1480 add_sequences(&existing, &new_sequences, &new_names, &scoring, use_fft)
1481 }
1482 }
1483
1484 /// Same as [`add_to_alignment`] with `keeplength = true`, but also
1485 /// returns the per-added-sequence list of dropped insertion runs.
1486 /// Used by `--mapout` / `--compactmapout` to emit the `.map`
1487 /// file. Each entry is `(start_pos_in_addbk_0based, run_length)`.
1488 pub fn add_to_alignment_with_map(
1489 &self,
1490 existing_input: &SequenceSet,
1491 new_input: &SequenceSet,
1492 ) -> (MultipleAlignment, Vec<Vec<(usize, usize)>>) {
1493 let seq_type = existing_input.seq_type;
1494 let scoring_model = if seq_type.is_nucleotide() {
1495 ScoringModel::Dna
1496 } else {
1497 self.scoring_model
1498 };
1499 let mut scoring = build_context(scoring_model, seq_type);
1500 if let Some(op) = self.gap_open {
1501 let ppenalty = -(op * 1000.0) as i32;
1502 let scale = if seq_type.is_nucleotide() { 3.0 * 600.0 / 1000.0 } else { 600.0 / 1000.0 };
1503 scoring.gap.open = (scale * ppenalty as f64 + 0.5) as i32;
1504 }
1505 let use_fft = !self.nofft && matches!(
1506 self.mode,
1507 AlignmentMode::FftNs2 | AlignmentMode::FftNsi { .. }
1508 );
1509 let existing = MultipleAlignment {
1510 sequences: existing_input.sequences.iter().map(|s| s.data.clone()).collect(),
1511 names: existing_input.sequences.iter().map(|s| s.name.clone()).collect(),
1512 score: 0.0,
1513 step_trace: Vec::new(), guide_tree: None, first_pass_sequences: None, distance_matrix: None,
1514 };
1515 let new_sequences: Vec<Vec<u8>> = new_input.sequences.iter().map(|s| s.data.clone()).collect();
1516 let new_names: Vec<String> = new_input.sequences.iter().map(|s| s.name.clone()).collect();
1517 crate::add::add_sequences_keeplength_with_map(
1518 &existing, &new_sequences, &new_names, &scoring, use_fft,
1519 )
1520 }
1521
1522 /// Convenience: read FASTA file and align.
1523 pub fn align_file(&self, path: &std::path::Path) -> Result<MultipleAlignment, mafft_io::IoError> {
1524 let input = read_fasta(path)?;
1525 Ok(self.align(&input))
1526 }
1527}
1528
1529/// Compute pairwise 6-tuple distances from raw (unaligned) sequences.
1530/// Matches C's default distance computation using `commonsextet_p`.
1531fn compute_distance_matrix_from_seqs(sequences: &[Vec<u8>]) -> DistanceMatrix {
1532 let nseq = sequences.len();
1533 let pairs: Vec<(usize, usize, f64)> = (0..nseq)
1534 .into_par_iter()
1535 .flat_map(|i| {
1536 let seqs = sequences;
1537 ((i + 1)..nseq).into_par_iter().map(move |j| {
1538 let d = ktuple_distance(&seqs[i], &seqs[j], 6);
1539 (i, j, d)
1540 })
1541 })
1542 .collect();
1543
1544 let mut dm = DistanceMatrix::new(nseq);
1545 for (i, j, d) in pairs {
1546 dm.set(i, j, d);
1547 }
1548 dm
1549}
1550
1551/// Compute pairwise distances from aligned sequences using scoring matrix.
1552///
1553/// Ports C's `msadistmtxthread` which uses `naivepairscorefast` for the
1554/// retree distance computation. This produces different distances from
1555/// simple identity distance and thus a different guide tree.
1556fn compute_distance_matrix_scoring(
1557 sequences: &[Vec<u8>],
1558 matrix: &[Vec<i32>],
1559 amino_map: &[u8; 256],
1560 penalty_dist: i32,
1561) -> DistanceMatrix {
1562 let nseq = sequences.len();
1563 let pairs: Vec<(usize, usize, f64)> = (0..nseq)
1564 .into_par_iter()
1565 .flat_map(|i| {
1566 let seqs = sequences;
1567 ((i + 1)..nseq).into_par_iter().map(move |j| {
1568 let d = scoring_matrix_distance(&seqs[i], &seqs[j], matrix, amino_map, penalty_dist);
1569 (i, j, d)
1570 })
1571 })
1572 .collect();
1573
1574 let mut dm = DistanceMatrix::new(nseq);
1575 for (i, j, d) in pairs {
1576 dm.set(i, j, d);
1577 }
1578 dm
1579}
1580
1581
1582#[cfg(test)]
1583mod tests {
1584 use super::*;
1585
1586 /// C `constants.c:316-322` (nucleotide) vs `:672-677` (protein). The
1587 /// nucleotide `3 *` on the gap penalties is what keeps DNA L-INS-i /
1588 /// G-INS-i / E-INS-i byte-identical to C; the offset stays at `1 *`.
1589 /// C's `dndpre` default `poffset` differs by alphabet: `DEFAULTOFS_N`
1590 /// (`DNA.h:3`) vs `DEFAULTOFS_B` (`blosum.c:3`). Using the protein value
1591 /// for DNA silently reorders the refinement guide tree.
1592 /// C selects its refinement implementation on `nthread > 0`
1593 /// (`tditeration.c:1433`), and its script maps both *no* `--thread` and
1594 /// `--thread 0` to `dvtditr -C 0`. So 0 must mean the single-threaded
1595 /// convergence rule, and anything >= 1 the `athread` per-cycle rule.
1596 #[test]
1597 fn nthread_selects_the_convergence_rule_like_c() {
1598 let per_cycle = |nthread: usize| nthread > 0;
1599 assert!(!per_cycle(0), "no --thread / --thread 0 => C -C 0 => single-threaded rule");
1600 assert!(per_cycle(1), "--thread 1 => C -C 1 => athread rule");
1601 assert!(per_cycle(4));
1602 // Default engine must not opt into the athread rule.
1603 assert_eq!(MafftEngine::new(AlignmentMode::FftNs2).nthread, 0);
1604 }
1605
1606 #[test]
1607 fn dndpre_offset_shift_mirrors_constants_c() {
1608 // offset = (int)( 600/1000 * poffset + 0.5 ); shift = -offset.
1609 let c_offset = |poffset: i32| (0.6 * poffset as f64 + 0.5) as i32;
1610 assert_eq!(c_offset(-369), -220, "nucleotide DEFAULTOFS_N");
1611 assert_eq!(c_offset(-123), -73, "protein DEFAULTOFS_B");
1612 assert_eq!(dndpre_offset_shift(true), 220, "DNA must not use the protein shift");
1613 assert_eq!(dndpre_offset_shift(false), 73);
1614 }
1615
1616 #[test]
1617 fn pair_penalty_scales_mirror_constants_c() {
1618 let (gap, off) = pair_penalty_scales(true);
1619 assert_eq!(gap, 3.0 * 600.0 / 1000.0, "nucleotide gap scale must carry C's `3 *`");
1620 assert_eq!(off, 600.0 / 1000.0, "nucleotide offset scale is `1 *`, not `3 *`");
1621 let (gap, off) = pair_penalty_scales(false);
1622 assert_eq!(gap, 600.0 / 1000.0);
1623 assert_eq!(off, 600.0 / 1000.0);
1624 // Worked values for the L-INS-i defaults (lgop=-2.00 → ppenalty=-2000,
1625 // laof=0.100 → poffset=100), rounded the way C's `(int)(x + 0.5)` does:
1626 let cc = |pp: i32, sc: f64| ((sc * pp as f64) + 0.5) as i32;
1627 assert_eq!(cc(-2000, pair_penalty_scales(true).0), -3599); // C: -3600+0.5 → -3599
1628 assert_eq!(cc(-2000, pair_penalty_scales(false).0), -1199);
1629 assert_eq!(cc(100, pair_penalty_scales(true).1), 60);
1630 }
1631 use mafft_types::{Sequence, SeqType};
1632
1633 fn make_test_input() -> SequenceSet {
1634 SequenceSet {
1635 sequences: vec![
1636 Sequence { name: "s1".into(), data: b"ACDEFGHIKLMNP".to_vec() },
1637 Sequence { name: "s2".into(), data: b"ACDEFHIKLMNP".to_vec() },
1638 Sequence { name: "s3".into(), data: b"ACDEHIKLMNP".to_vec() },
1639 ],
1640 seq_type: SeqType::Protein,
1641 }
1642 }
1643
1644 #[test]
1645 fn engine_fftns2() {
1646 let engine = MafftEngine::new(AlignmentMode::FftNs2);
1647 let msa = engine.align(&make_test_input());
1648 assert_eq!(msa.nseq(), 3);
1649 let w = msa.width();
1650 assert!(w >= 13);
1651 for seq in &msa.sequences { assert_eq!(seq.len(), w); }
1652 }
1653
1654 #[test]
1655 fn engine_with_refinement() {
1656 let engine = MafftEngine::new(AlignmentMode::FftNsi { iterations: 5 });
1657 let msa = engine.align(&make_test_input());
1658 assert_eq!(msa.nseq(), 3);
1659 let w = msa.width();
1660 for seq in &msa.sequences { assert_eq!(seq.len(), w); }
1661 }
1662
1663 #[test]
1664 fn engine_two_sequences() {
1665 let input = SequenceSet {
1666 sequences: vec![
1667 Sequence { name: "a".into(), data: b"ACDEFGHIK".to_vec() },
1668 Sequence { name: "b".into(), data: b"ACDEFGHIK".to_vec() },
1669 ],
1670 seq_type: SeqType::Protein,
1671 };
1672 let engine = MafftEngine::default();
1673 let msa = engine.align(&input);
1674 assert_eq!(msa.nseq(), 2);
1675 assert_eq!(msa.sequences[0], msa.sequences[1]);
1676 }
1677
1678 #[test]
1679 fn engine_linsi_mode() {
1680 let engine = MafftEngine::new(AlignmentMode::LInsi { iterations: 2 });
1681 let msa = engine.align(&make_test_input());
1682 assert_eq!(msa.nseq(), 3);
1683 let w = msa.width();
1684 for seq in &msa.sequences { assert_eq!(seq.len(), w); }
1685 }
1686
1687 #[test]
1688 fn engine_retree_1_vs_2() {
1689 let input = make_test_input();
1690 let msa1 = MafftEngine::new(AlignmentMode::FftNs2).with_retree(1).align(&input);
1691 let msa2 = MafftEngine::new(AlignmentMode::FftNs2).with_retree(2).align(&input);
1692 // Both should produce valid alignments
1693 assert_eq!(msa1.nseq(), 3);
1694 assert_eq!(msa2.nseq(), 3);
1695 let w1 = msa1.width();
1696 let w2 = msa2.width();
1697 for seq in &msa1.sequences { assert_eq!(seq.len(), w1); }
1698 for seq in &msa2.sequences { assert_eq!(seq.len(), w2); }
1699 }
1700
1701 /// Guard: refinement tree uses scoring-matrix distance, not identity distance.
1702 ///
1703 /// C's dvtditr.c reads scoring-matrix-based distances from the hat2 file
1704 /// (written during the retree pass) to build the UPGMA tree for refinement.
1705 /// This test verifies that the engine calls `compute_distance_matrix_scoring`
1706 /// (not `compute_distance_matrix_from_alignment`) by checking that the
1707 /// refinement path in the source uses `scoring.substitution_matrix`.
1708 ///
1709 /// Functional check: FFT-NS-i with refinement produces a valid alignment
1710 /// on a dataset where scoring-matrix vs identity distance would yield
1711 /// different guide trees (sequences with varying conservation levels).
1712 #[test]
1713 fn engine_refinement_uses_scoring_matrix_distance() {
1714 let input = SequenceSet {
1715 sequences: vec![
1716 Sequence { name: "s1".into(), data: b"ACDEFGHIKLMNPQRSTVWY".to_vec() },
1717 Sequence { name: "s2".into(), data: b"ACDEFGHIKLMNPQRSTVWY".to_vec() },
1718 Sequence { name: "s3".into(), data: b"WWWWWWWWWWWWWWWWWWWW".to_vec() },
1719 Sequence { name: "s4".into(), data: b"ACDHIKLMNP".to_vec() },
1720 Sequence { name: "s5".into(), data: b"ACDEHIKLMNPQR".to_vec() },
1721 ],
1722 seq_type: SeqType::Protein,
1723 };
1724 let engine = MafftEngine::new(AlignmentMode::FftNsi { iterations: 5 });
1725 let msa = engine.align(&input);
1726 assert_eq!(msa.nseq(), 5);
1727 let w = msa.width();
1728 for (i, seq) in msa.sequences.iter().enumerate() {
1729 assert_eq!(seq.len(), w, "sequence {i} has wrong width after refinement");
1730 let residues = seq.iter().filter(|&&c| c != b'-').count();
1731 assert_eq!(residues, input.sequences[i].data.len(),
1732 "sequence {i} lost residues during refinement");
1733 }
1734 }
1735
1736 /// Guard: engine passes cut=0.0 (default) to refinement params.
1737 ///
1738 /// Verifies the engine uses `..Default::default()` which has cut=0.0,
1739 /// not a hardcoded nonzero value.
1740 #[test]
1741 fn engine_refinement_params_use_default_cut() {
1742 // The RefinementParams default must have cut=0.0.
1743 // The engine constructs params with `..Default::default()`,
1744 // so this transitively guards the engine's behavior.
1745 let params = crate::refinement::RefinementParams::default();
1746 assert_eq!(params.cut, 0.0);
1747 }
1748
1749}