Skip to main content

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, &params, local_hom.as_ref(),
1331                        );
1332                    } else if use_segmented {
1333                        crate::refinement::segmented_iterative_refine(
1334                            &mut msa, &topo, &scoring, &params, local_hom.as_ref(),
1335                        );
1336                    } else {
1337                        iterative_refine(
1338                            &mut msa, &topo, &scoring, &params, 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}