Skip to main content

mafft_core/
refinement.rs

1/// Iterative refinement (tree-dependent iteration).
2///
3/// Ports the C `TreeDependentIteration()` from tditeration.c.
4///
5/// Repeatedly re-aligns pairs of groups defined by the guide tree,
6/// accepting improvements and rejecting regressions, until convergence.
7///
8/// Key insight from the C code: at each tree branch, ALL sequences are
9/// split into two groups (subtree vs everything else). There are never
10/// "uninvolved" sequences — every sequence is in one group or the other.
11///
12/// Branch enumeration matches C exactly:
13/// - For each topology step, both sides (k=0: left, k=1: right) are
14///   processed, EXCEPT the root step (last step) where only k=1 (right)
15///   is used (since at the root, left-vs-complement and right-vs-complement
16///   produce the same split, just flipped).
17/// - Even iterations traverse steps forward (0 → N-1), odd iterations
18///   traverse backward (N-1 → 0). Within each step, k always goes 0→1.
19/// - Total branches per iteration: (nseq-1)*2 - 1.
20
21use mafft_types::fp::fmadd;
22use mafft_align::{
23    profile_align, profile_align_imp,
24    profile_align_imp_with_boundary, profile_align_imp_multimtx,
25    BoundaryFreqs, MultiMtx,
26    build_imp_matrix, FASTATHRESHOLD_DEFAULT,
27    Profile, GapModel, AlignOp,
28};
29use mafft_fft::{alignable_segments, SegmentParams};
30use mafft_tree::{Topology, BranchWeights};
31use mafft_types::{ScoringContext, LocalHomologyTable};
32
33use crate::progressive::MultipleAlignment;
34
35/// Parameters controlling iterative refinement.
36#[derive(Debug, Clone)]
37pub struct RefinementParams {
38    /// Maximum number of iterations.
39    pub max_iterations: usize,
40    /// Score improvement threshold (fraction of old score).
41    /// C default is 0.0 (accept only strict improvements).
42    pub cut: f64,
43    /// Whether to use FFT-accelerated alignment during refinement.
44    pub use_fft: bool,
45    /// `--leavegappyregion` / `--legacygappenalty` — propagated into
46    /// the inner `GapModel` so the profile DP treats every column as
47    /// fully nongap (`legacygapcost = 1`, `Salignmm.c:1604-1610`).
48    pub legacy_gap_cost: bool,
49    /// `--allowshift` warp/shift penalty for the refinement DP. C's
50    /// `dvtditr` receives `-Q 2.0` → `penalty_shift_factor = 2.0` (< 10)
51    /// → `trywarp = 1`, so the warp DP fires in refinement with
52    /// `penalty_shift = 2.0 * penalty`. `None` = no warp (default).
53    pub shift: Option<f64>,
54    /// `--allowshift` `specificityconsideration` (C `dvtditr -s 0.8`).
55    /// When `> 0`, each refinement branch scores sequence pairs with
56    /// distance-binned matrices (the `_variousdist` multi-matrix DP).
57    /// 0.0 = disabled (single matrix).
58    pub unalign_level: f64,
59    /// Floor for per-sequence weights in the intergroup-score
60    /// accumulation. Mirrors C's `tbfast -W $minimumweight`
61    /// (`scripts/mafft:1029` / default 0.00001). Sequences with weight
62    /// below this floor get clamped up. Set to the C default if not
63    /// otherwise overridden by `--minimumweight`.
64    pub minimum_weight: f64,
65    /// `--bestfirst` parallelisation strategy. C MAFFT's BAATARI2
66    /// (default) walks each branch sequentially in topology order and
67    /// accepts improvements immediately. BESTFIRST evaluates all
68    /// branches against the same baseline alignment, picks the one
69    /// with the largest gain, applies it, repeats. C's BESTFIRST is
70    /// deterministic across thread counts (verified --thread 1, 4,
71    /// 8 produce byte-identical output) — threads only parallelise
72    /// the per-branch evaluation.
73    pub bestfirst: bool,
74    /// Per-(step, side) skip flags for `--skipiterate F` small-F mode.
75    /// `skip_branches[step_idx]` is `(skip_left, skip_right)` —
76    /// when a side is `true`, that branch's realign attempt is
77    /// skipped entirely (mirrors C `dvtditr.c:999/1004`'s
78    /// `skipthisbranch[j][k] = 1`). Empty = no skips (default
79    /// refinement). Populated by the caller from
80    /// `mafft_tree::generate_subalignments_table` output.
81    pub skip_branches: Vec<(bool, bool)>,
82    /// Use C's `athread` refinement rules instead of the single-threaded
83    /// `TreeDependentIteration` ones.
84    ///
85    /// C picks the refinement implementation on `nthread > 0`
86    /// (`tditeration.c:1433`). With one worker thread `athread` is fully
87    /// deterministic, but it walks the tree and stops by different rules
88    /// than the single-threaded loop, and every one of them is modelled
89    /// here:
90    ///
91    /// * **Branch order.** `TreeDependentIteration` alternates direction:
92    ///   even cycles walk `l = 0 .. locnjob-2`, odd cycles walk it in
93    ///   reverse (`tditeration.c:1641-1648`). `athread` hands out
94    ///   `branchtable[jobpos]` for `jobpos = 0 .. nbranch` (`:728-730`),
95    ///   and `branchtable` is the identity unless `randomseed != 0`
96    ///   shuffles it (`:522`, default seed 0) — so the ascending order is
97    ///   used in **every** cycle.
98    /// * **Convergence.** `TreeDependentIteration` checks
99    ///   `converged >= locnjob * 2` after **every branch** and `goto end`s
100    ///   immediately, mid-cycle (`:2328-2342`). `athread`'s collector
101    ///   checks once per **cycle** whether any branch gained
102    ///   (`maxgain > 0.0`, `:590`); if none did it prints `Converged.` and
103    ///   sets `*collectingpt = -1`, which only takes effect at the top of
104    ///   the next cycle (`:527-551`), so the converging cycle always runs
105    ///   to completion.
106    /// * **Oscillation.** `TreeDependentIteration` compares each branch's
107    ///   `tscore` with the same branch's score 2, 4, 6 … cycles earlier and
108    ///   exits immediately (`:2345-2372`). `athread` has two different
109    ///   checks, both of which stop at the **end** of the cycle: the worker
110    ///   compares the branch's `tscore` with the same branch's score in
111    ///   **every** earlier cycle `<= iterate-2` (step 1, `:1217-1230`) and
112    ///   raises `*finishpt` (`Converged2.`, `:636-637`); the collector
113    ///   compares the `tscore` of the **last accepted** branch of the cycle
114    ///   (`tscorelist[thread]`, `:1184`) with the same quantity from cycles
115    ///   `1 .. iterate-1` (`Oscillating?`, `:609-619`).
116    /// * **Skipped branches** (`--skipiterate`) are not aligned but still
117    ///   record `tscore = mscore` (`:1064-1067`, `:1234`), so they take part
118    ///   in the `Converged2.` check.
119    ///
120    /// The convergence difference is visible in C's own output: at
121    /// `maxiterate 2`, 22 of 85 segments print `Converged.` alone
122    /// (converged in cycle 0, so cycle 1 never starts), 56 print
123    /// `Converged.` and `Reached 2` (converged in the last cycle, so the
124    /// loop ended normally), and 7 print `Reached 2` alone. The order
125    /// difference is what separated `--thread 1` from C on
126    /// `mtb_cds_120x1400.fa` by one gap column in one sequence (`s97`).
127    pub per_cycle_convergence: bool,
128}
129
130impl Default for RefinementParams {
131    fn default() -> Self {
132        Self {
133            max_iterations: 100,
134            cut: 0.0,
135            use_fft: false,
136            legacy_gap_cost: false,
137            shift: None,
138            unalign_level: 0.0,
139            minimum_weight: 0.00001,
140            bestfirst: false,
141            skip_branches: Vec::new(),
142            per_cycle_convergence: false,
143        }
144    }
145}
146
147/// Per-branch input for the `--allowshift` multi-distance-class refinement
148/// DP. `distarr[leaf]` is the tree distance from each leaf to the branch
149/// being refined (`BranchWeights::dist_from_a_branch`); pairs are binned by
150/// `distarr[g1[i]] + distarr[g2[j]]` (C `smalldistmtx`, `USEDISTONTREE=1`).
151struct MultiMtxInput<'a> {
152    distarr: &'a [f64],
153    unalign_level: f64,
154}
155
156/// Per-branch multi-distance-class context (C `makescoringmatrices` +
157/// `classifypairs` + masklists), computed once per `realign_all_constrained_fft`
158/// and reused across all FFT segments. Only the per-segment cpmx column
159/// profiles (`cpmx1s`/`cpmx2s`), which depend on the stripped segment, are
160/// rebuilt per segment; class assignment and matrices are branch-global.
161struct MmBranchCtx {
162    /// `matrices[c]` — substitution matrix for distance class `c`.
163    matrices: Vec<Vec<Vec<f64>>>,
164    /// `eff1s[c][i]` / `eff2s[c][j]` — per-class member weights (0 if member
165    /// is in no pair of class `c`).
166    eff1s: Vec<Vec<f64>>,
167    eff2s: Vec<Vec<f64>>,
168    /// Spurious-pair masks per class for `match_calc_del`.
169    mask1: Vec<Vec<usize>>,
170    mask2: Vec<Vec<usize>>,
171}
172
173/// A branch identifier for oscillation tracking: (step_index, side).
174/// side 0 = left, side 1 = right.
175type BranchId = (usize, usize);
176
177/// Build the per-step branch splits from a topology, matching C's enumeration.
178///
179/// For each topology step, both sides (k=0: left vs complement, k=1: right vs
180/// complement) are included — EXCEPT the root step (last step) where only k=1
181/// is included. At the root, left-vs-complement and right-vs-complement are
182/// identical splits (just flipped), so C skips the redundant one.
183///
184/// Returns: `branch_map[step_idx]` = list of `(side, group1, group2)`.
185/// Total branches = `(nseq - 1) * 2 - 1`.
186fn build_branch_map(
187    topology: &Topology,
188    nseq: usize,
189) -> Vec<Vec<(usize, Vec<usize>, Vec<usize>)>> {
190    let nsteps = topology.steps.len();
191    let root_idx = nsteps - 1;
192    let all_indices: Vec<usize> = (0..nseq).collect();
193
194    let mut branch_map: Vec<Vec<(usize, Vec<usize>, Vec<usize>)>> = Vec::with_capacity(nsteps);
195    for (step_idx, step) in topology.steps.iter().enumerate() {
196        let is_root = step_idx == root_idx;
197        let mut sides = Vec::new();
198
199        if !is_root {
200            // Side 0: step.left vs complement
201            let complement: Vec<usize> = all_indices
202                .iter()
203                .filter(|i| !step.left.contains(i))
204                .copied()
205                .collect();
206            if !step.left.is_empty() && !complement.is_empty() {
207                sides.push((0, step.left.clone(), complement));
208            }
209        }
210
211        // Side 1: step.right vs complement
212        let complement: Vec<usize> = all_indices
213            .iter()
214            .filter(|i| !step.right.contains(i))
215            .copied()
216            .collect();
217        if !step.right.is_empty() && !complement.is_empty() {
218            sides.push((1, step.right.clone(), complement));
219        }
220
221        branch_map.push(sides);
222    }
223    branch_map
224}
225
226/// Is `MAFFT_RS_REFINE_STATS` set? When it is, the refinement entry points
227/// print a one-line work summary to stderr.
228///
229/// C's `dvtditr` reports its refinement work directly (`Segment n/N`, then a
230/// `IIII-BBBB-S ... accepted/rejected` line per branch), so the two sides can
231/// be compared cycle-for-cycle. Rust had no equivalent, which made
232/// "did both run the same number of cycles?" unanswerable from outside and
233/// any speed comparison meaningless. Off by default, so CLI output and the
234/// `Progress` sink are unchanged.
235#[derive(Default)]
236struct RefineCounters {
237    /// Branches visited (the re-alignment DP ran). Comparable to the count of
238    /// `IIII-BBBB-S` lines C's `dvtditr` prints.
239    visited: usize,
240    /// Of those, branches whose re-alignment actually changed the columns —
241    /// C prints these as `accepted.`/`rejected.` rather than `identical`.
242    branches: usize,
243    accepted: usize,
244    /// Why the cycle loop ended: `maxiter`, `converged` or `oscillation`.
245    exit: &'static str,
246}
247
248fn refine_stats_enabled() -> bool {
249    use std::sync::OnceLock;
250    static ON: OnceLock<bool> = OnceLock::new();
251    *ON.get_or_init(|| std::env::var_os("MAFFT_RS_REFINE_STATS").is_some())
252}
253
254/// Iteratively refine a multiple alignment.
255///
256/// At each tree branch, splits ALL sequences into two groups (subtree vs
257/// rest), re-aligns the two groups, and accepts improvements.
258pub fn iterative_refine(
259    alignment: &mut MultipleAlignment,
260    topology: &Topology,
261    scoring: &ScoringContext,
262    params: &RefinementParams,
263    constraints: Option<&LocalHomologyTable>,
264) -> usize {
265    // Thin reporting wrapper so every exit path (max-iterations, convergence,
266    // oscillation) is counted in one place — see `refine_stats_enabled`.
267    let nseq0 = alignment.nseq();
268    let len0 = alignment.sequences.first().map_or(0, |s| s.len());
269    let mut counters = RefineCounters { exit: "maxiter", ..Default::default() };
270    let iterations =
271        iterative_refine_inner(alignment, topology, scoring, params, constraints, &mut counters);
272    if refine_stats_enabled() {
273        eprintln!(
274            "refine: nseq={nseq0} len={len0} cycles={iterations}/{} visited={} changed={} accepted={} exit={}",
275            params.max_iterations, counters.visited, counters.branches, counters.accepted, counters.exit,
276        );
277    }
278    iterations
279}
280
281fn iterative_refine_inner(
282    alignment: &mut MultipleAlignment,
283    topology: &Topology,
284    scoring: &ScoringContext,
285    params: &RefinementParams,
286    constraints: Option<&LocalHomologyTable>,
287    counters: &mut RefineCounters,
288) -> usize {
289    let nseq = alignment.nseq();
290    // C refines two sequences too: `dvtditr.c:704-708` sets
291    // `weight = 0; niter = 1` for `njob == 2` rather than skipping, and
292    // `tditeration.c:1425` gates branch-weight computation on
293    // `locnjob > 2`, so the pair is refined once, unweighted.
294    // `BranchWeights` already yields uniform weights at nseq <= 2 and the
295    // engine caps the iteration count, so only the early-return had to go.
296    if nseq < 2 || topology.steps.is_empty() {
297        return 0;
298    }
299
300    let branch_weights = BranchWeights::new(topology);
301    let global_weights = mafft_tree::sequence_weights(topology);
302    let use_global_weights = std::env::var("RUST_MAFFT_GLOBAL_WEIGHTS").is_ok();
303    // C MAFFT's `dvtditr` invocation in `scripts/mafft` does NOT pass
304    // `-g $gexp` (the `--exp` extension penalty). Only `disttbfast`
305    // gets `-g`. As a result C's refinement DP always sees
306    // `penalty_ex = 0`, regardless of what `--exp` the user passed —
307    // confirmed by C's progress output showing `alg=A, ..., -0.00,
308    // -0.00` for the refinement-phase alignment vs `..., -0.00,
309    // +0.10` for the progressive disttbfast phase. Match that: zero
310    // out the extend penalty in refinement's GapModel so we mirror
311    // C exactly. Without this our refinement keeps shortening the
312    // alignment under non-zero `--exp` while C's keeps the
313    // progressive width.
314    let mut gap = GapModel::new(scoring.gap.open as f64, 0.0)
315        .with_legacy_gap_cost(params.legacy_gap_cost);
316    if let Some(s) = params.shift {
317        gap = gap.with_shift(s);
318    }
319
320
321    let mut converged_count = 0usize;
322    let convergence_target = nseq * 2;
323
324    let nsteps = topology.steps.len();
325    let branch_map = build_branch_map(topology, nseq);
326
327    // Per-branch score history for oscillation detection.
328    // history[iteration][(step_idx, side)] = score after processing that branch.
329    let mut history: Vec<std::collections::HashMap<BranchId, f64>> = Vec::new();
330
331    // `athread` bookkeeping (only read when `params.per_cycle_convergence`):
332    // C's `*finishpt` (`tditeration.c:1230`), and `tscorehistory[iterate]`
333    // = tscore of the cycle's last accepted branch (`:619`).
334    let mut athread_finish = false;
335    let mut athread_tscorehistory: Vec<f64> = Vec::new();
336
337    let mut iteration = 0;
338    for iter in 0..params.max_iterations {
339        iteration = iter + 1;
340        let mut any_change = false;
341        let mut iter_scores: std::collections::HashMap<BranchId, f64> = std::collections::HashMap::new();
342        // C `tscorelist[thread_no]` (`tditeration.c:1184`): reset per cycle
343        // (`:520`), overwritten on every accept.
344        let mut last_accepted_tscore: Option<f64> = None;
345
346        let step_order = step_order(iter, nsteps, params.per_cycle_convergence);
347
348        for &step_idx in &step_order {
349            for (side, group1, group2) in &branch_map[step_idx] {
350                let branch_id: BranchId = (step_idx, *side);
351
352                // `--skipiterate F` small-F: per-(step, side) skip
353                // flags (port of C `dvtditr.c:1059-1066`'s
354                // `skipthisbranch[]`). Skipped branches are
355                // silently dropped — they do NOT count toward the
356                // convergence target, mirroring C `tditeration.c:2358`
357                // (`identity = 1; tscore = mscore` for skipped
358                // branches, which `tditeration.c:2255-2256` then
359                // treats as "no improvement" without bumping the
360                // converge counter).
361                let skipped = params.skip_branches.get(step_idx)
362                    .map(|&(l, r)| if *side == 0 { l } else { r })
363                    .unwrap_or(false);
364                // `athread` (`tditeration.c:1064-1067`, `:1234`) still computes the
365                // branch's `mscore` and records `tscore = mscore` for the
366                // `Converged2.` check, so in that mode fall through to the
367                // scoring and only skip the re-alignment.
368                if skipped && !params.per_cycle_convergence {
369                    iter_scores.insert(branch_id, 0.0);
370                    continue;
371                }
372
373                if let Ok(f) = std::env::var("RS_DISTARR_DUMP") {
374                    use std::io::Write;
375                    let da = branch_weights.dist_from_a_branch(topology, step_idx, *side);
376                    if let Ok(mut fp) = std::fs::OpenOptions::new().create(true).append(true).open(&f) {
377                        let _ = write!(fp, "DISTARR iter={} l={} k={}:", iter, step_idx, side);
378                        for v in &da { let _ = write!(fp, " {:.17e}", v); }
379                        let _ = writeln!(fp);
380                    }
381                }
382
383                if let Ok(f) = std::env::var("RS_PRE_BRANCH") {
384                    use std::io::Write;
385                    if let Ok(mut fp) = std::fs::OpenOptions::new().create(true).append(true).open(&f) {
386                        let _ = writeln!(fp, "R_PREBR iter={} step={} side={} clus1={} clus2={}", iter, step_idx, side, group1.len(), group2.len());
387                        for (i, s) in alignment.sequences.iter().enumerate() {
388                            let mut h: u64 = 5381;
389                            for &c in s { h = h.wrapping_mul(33).wrapping_add(c as u64); }
390                            let _ = writeln!(fp, "  R_seq[{}] len={} hash={:x}", i, s.len(), h);
391                        }
392                    }
393                }
394
395                let weights = if use_global_weights {
396                    global_weights.clone()
397                } else {
398                    branch_weights.weights_for_branch(topology, step_idx, *side)
399                };
400
401                if let Ok(f) = std::env::var("RS_BRANCH_WEIGHTS") {
402                    use std::io::Write;
403                    if let Ok(mut fp) = std::fs::OpenOptions::new().create(true).append(true).open(&f) {
404                        let _ = writeln!(fp, "R_BW iter={} step={} side={}", iter, step_idx, side);
405                        for (i, &w) in weights.iter().enumerate() {
406                            let _ = writeln!(fp, "  R_bw[{}]={:.17e}", i, w);
407                        }
408                    }
409                }
410
411                // Group-local sum-1 normalized weights (matches C's
412                // fastconjuction_noname). Used both for `compute_impmatch_diagonal`
413                // and any future per-cluster averaging. Floor is C's
414                // `minimumweight` (`scripts/mafft:1029`, overridable via
415                // `--minimumweight`).
416                let w1: Vec<f64> = group1.iter().map(|&i| weights[i].max(params.minimum_weight)).collect();
417                let w2: Vec<f64> = group2.iter().map(|&i| weights[i].max(params.minimum_weight)).collect();
418                let s1w: f64 = w1.iter().sum();
419                let s2w: f64 = w2.iter().sum();
420                let w1n: Vec<f64> = if s1w > 0.0 { w1.iter().map(|w| w / s1w).collect() } else { vec![1.0; group1.len()] };
421                let w2n: Vec<f64> = if s2w > 0.0 { w2.iter().map(|w| w / s2w).collect() } else { vec![1.0; group2.len()] };
422
423                // C's mscore = oimpmatchdouble + tmpdouble (tditeration.c:953):
424                // intergroup substitution score + impmatch (sum of impmtx[i][i]
425                // over the current alignment's columns). We compute the same.
426                let old_sub = compute_split_score(
427                    group1, group2, &alignment.sequences, &weights, scoring,
428                    params.minimum_weight,
429                );
430                let old_imp = if let Some(lh) = constraints {
431                    compute_impmatch_diagonal(
432                        group1, group2, &alignment.sequences, &w1n, &w2n, lh,
433                    )
434                } else { 0.0 };
435                let old_score = old_sub + old_imp;
436
437                // `--allowshift`: per-branch distances-from-tip drive the
438                // multi-distance-class matrix selection (C `distFromABranch`
439                // + `classifypairs`). Computed here where `branch_weights`,
440                // `topology`, and the branch `(step_idx, side)` are in scope.
441                let mm_distarr: Option<Vec<f64>> = if params.unalign_level > 0.0 {
442                    Some(branch_weights.dist_from_a_branch(topology, step_idx, *side))
443                } else {
444                    None
445                };
446                let mm_input = mm_distarr.as_ref().map(|d| MultiMtxInput {
447                    distarr: d,
448                    unalign_level: params.unalign_level,
449                });
450
451                let new_seqs = if skipped {
452                    None
453                } else {
454                    counters.visited += 1;
455                    realign_all(
456                        group1, group2, &alignment.sequences, &weights, scoring, &gap,
457                        constraints, params.use_fft, mm_input.as_ref(),
458                        params.minimum_weight,
459                    )
460                };
461
462                if let Some((new_seqs, _new_score, dp_impmatch)) = new_seqs {
463                    // C's identity check (tditeration.c:2184-2185): compare only
464                    // the representative sequences s1=memlist1[0], s2=memlist2[0]
465                    // (from OneClusterAndTheOther_fast in tddis.c:834-835).
466                    // `group1` is memlist1, `group2` is memlist2, so s1=group1[0],
467                    // s2=group2[0]. Checking ALL sequences would incorrectly treat
468                    // column-rearrangements that preserve the two representatives
469                    // as "changed", causing spurious accepts.
470                    let s1 = group1[0];
471                    let s2 = group2[0];
472                    let changed = alignment.sequences[s1] != new_seqs[s1]
473                        || alignment.sequences[s2] != new_seqs[s2];
474                    // COMPAT: this two-row test decides whether a whole
475                    // re-alignment is kept, and it is deliberately NOT a
476                    // full comparison. On a 120x1.4kb FFT-NS-i run, 213
477                    // re-alignments per run have both representatives
478                    // unchanged while other rows DID change; every one of
479                    // them is discarded here. C does exactly the same: its
480                    // identity test is `!strcmp(aseq[s1],bseq[s1]) *
481                    // !strcmp(aseq[s2],bseq[s2])` (tditeration.c:2184-2185),
482                    // and the copy-back `strcpy( aseq[i], bseq[i] )` runs
483                    // only on the accept path (tditeration.c:1769) — so C
484                    // throws the same 213 away. Widening this to "any row
485                    // changed" looks like an obvious fix and is a
486                    // divergence: it would send those branches through the
487                    // score comparison, changing accept/reject decisions
488                    // and the `converged_count` sequence.
489
490                    if !changed {
491                        // Identical — no change, count toward convergence
492                        let tscore = old_score;
493                        if let Ok(f) = std::env::var("RS_REFINE_TRACE") {
494                            use std::io::Write;
495                            if let Ok(mut fp) = std::fs::OpenOptions::new().create(true).append(true).open(&f) {
496                                let _ = writeln!(fp, "NOTHREAD pid={} niter={} iter={} l={} k={} clus1={} clus2={} mscore={:.6} tscore={:.6} accept=0",
497                                    std::process::id(), params.max_iterations, iter, step_idx, side, group1.len(), group2.len(), old_score, tscore);
498                            }
499                        }
500                        iter_scores.insert(branch_id, tscore);
501                        converged_count += 1;
502                    } else {
503                        // C's tscore = impmatchdouble + tmpdouble (tditeration.c:1094):
504                        // intergroup score + new alignment's impmatch.
505                        let new_sub = compute_split_score(
506                            group1, group2, &new_seqs, &weights, scoring,
507                            params.minimum_weight,
508                        );
509                        let new_imp = if let Some(lh) = constraints {
510                            // Prefer the impmatch accumulated DURING the
511                            // segmented DP (C's `Falign_localhom` totalimpmatch:
512                            // per-segment backward sum, forward across segments).
513                            // This reproduces C's FP summation order exactly,
514                            // unlike a global diagonal sum which merges all
515                            // segments into one sweep (BB30028 fingerprint).
516                            // Fall back to the global diagonal sum only on the
517                            // non-FFT path (dvtditr always uses -F, so the
518                            // fallback is not hit by default L-INS-i).
519                            dp_impmatch.unwrap_or_else(|| compute_impmatch_diagonal(
520                                group1, group2, &new_seqs, &w1n, &w2n, lh,
521                            ))
522                        } else { 0.0 };
523                        let tscore = new_sub + new_imp;
524
525                        let threshold = old_score - params.cut / 100.0 * old_score;
526                        counters.branches += 1;
527                        if tscore > threshold { counters.accepted += 1; }
528                        if std::env::var("RUST_MAFFT_TRACE").is_ok() {
529                            eprintln!("ACCEPT iter={iter} step={step_idx} side={side} old={:.3} new={:.3} accept={}",
530                                old_score, tscore, tscore > threshold);
531                        }
532                        if let Ok(f) = std::env::var("RS_REFINE_TRACE") {
533                            use std::io::Write;
534                            if let Ok(mut fp) = std::fs::OpenOptions::new().create(true).append(true).open(&f) {
535                                let _ = writeln!(fp, "NOTHREAD pid={} niter={} iter={} l={} k={} clus1={} clus2={} mscore={:.6} tscore={:.6} accept={}",
536                                    std::process::id(), params.max_iterations, iter, step_idx, side, group1.len(), group2.len(), old_score, tscore,
537                                    if tscore > threshold { 1 } else { 0 });
538                            }
539                        }
540                        if tscore > threshold {
541                            alignment.sequences = new_seqs;
542                            if let Ok(f) = std::env::var("RS_ALIGN_HASH") {
543                                use std::io::Write;
544                                if let Ok(mut fp) = std::fs::OpenOptions::new().create(true).append(true).open(&f) {
545                                    let mut h: u64 = 5381;
546                                    for s in &alignment.sequences {
547                                        for &c in s { h = h.wrapping_mul(33).wrapping_add(c as u64); }
548                                    }
549                                    let w = alignment.sequences.first().map_or(0, |s| s.len());
550                                    let _ = writeln!(fp, "ACCEPT iter={} step={} side={} width={} hash={:x}", iter, step_idx, side, w, h);
551                                }
552                            }
553                            any_change = true;
554                            last_accepted_tscore = Some(tscore);
555                            converged_count = 0;
556                            iter_scores.insert(branch_id, tscore);
557                        } else {
558                            converged_count += 1;
559                            // C `tditeration.c:2336`: on reject, `tscore = mscore`
560                            // before `history[iterate][l][k] = tscore`. Storing the
561                            // (unchanged) mscore makes oscillation detection fire
562                            // when the same branch's mscore equals an earlier
563                            // iteration's mscore — which is what closes BB12019 /
564                            // BB12029 / BB30018 / BB40043's 4-line residuals.
565                            iter_scores.insert(branch_id, old_score);
566                        }
567                    }
568                } else {
569                    iter_scores.insert(branch_id, old_score);
570                    converged_count += 1;
571                }
572
573                if !params.per_cycle_convergence && converged_count >= convergence_target {
574                    counters.exit = "converged";
575                    return iteration;
576                }
577
578                if params.per_cycle_convergence {
579                    // `athread` worker, `tditeration.c:1217-1230`: the
580                    // branch's tscore equals its tscore in ANY earlier cycle
581                    // `ii <= iterate-2` (step 1, not 2) -> `*finishpt = 1`.
582                    // The collector turns that into `Converged2.` at the end
583                    // of the cycle (`:636-637`); the cycle itself completes.
584                    if iter >= 2 && !athread_finish {
585                        let tscore = iter_scores[&branch_id];
586                        for ii in (0..=iter - 2).rev() {
587                            if let Some(&prev_score) = history[ii].get(&branch_id) {
588                                if tscore == prev_score {
589                                    athread_finish = true;
590                                    break;
591                                }
592                            }
593                        }
594                    }
595                } else if iter >= 2 {
596                    // Oscillation detection: check if this branch's score matches
597                    // the score from 2, 4, 6... iterations ago (same branch).
598                    let tscore = iter_scores[&branch_id];
599                    let mut oscillating = false;
600                    let mut ii = history.len() as isize - 2; // iterate-2
601                    while ii >= 0 {
602                        if let Some(&prev_score) = history[ii as usize].get(&branch_id) {
603                            if tscore == prev_score {
604                                oscillating = true;
605                                break;
606                            }
607                        }
608                        ii -= 2;
609                    }
610                    if oscillating {
611                        counters.exit = "oscillation";
612                        return iteration;
613                    }
614                }
615            }
616        }
617
618        history.push(iter_scores);
619
620        // C's `TreeDependentIteration` does NOT exit on "no branches accepted
621        // this iteration". It keeps iterating until either the cumulative
622        // `converged` counter hits `nseq*2` (line 250 above) or oscillation
623        // triggers (line 270). Adding an early `!any_change` exit here makes
624        // Rust skip iterations C would have run — sometimes including one with
625        // identical branches that don't change the score but still bump the
626        // converged counter. The BB12019 / BB12029 / BB30018 / BB40043
627        // 4-line FFT-NS-i divergences come from that early exit.
628        if params.per_cycle_convergence {
629            // C `athread` collector (`tditeration.c:588-640`). Every stop
630            // here lands before the NEXT cycle (`:527-551`), so the cycle we
631            // just finished always counts.
632            match last_accepted_tscore {
633                // No branch gained (`maxgain > 0.0` false) -> `Converged.`
634                None => {
635                    counters.exit = "converged";
636                    return iteration;
637                }
638                // Some gain: compare the last accepted branch's tscore with
639                // the same quantity from cycles `iterate-1 .. 1` (`:609-619`,
640                // `i > 0`, so cycle 0 is never compared), then record it.
641                Some(tscore) => {
642                    let oscillating = (1..iter).rev().any(|i| athread_tscorehistory[i] == tscore);
643                    athread_tscorehistory.push(tscore);
644                    debug_assert_eq!(athread_tscorehistory.len(), iter + 1);
645                    if oscillating {
646                        counters.exit = "oscillation";
647                        return iteration;
648                    }
649                }
650            }
651            // `*finishpt` raised by a worker this cycle -> `Converged2.`
652            // (`:636-637`).
653            if athread_finish {
654                counters.exit = "converged2";
655                return iteration;
656            }
657        }
658        let _ = any_change;
659    }
660
661    iteration
662}
663
664/// The order in which a refinement cycle visits the tree's steps.
665///
666/// C `TreeDependentIteration` alternates direction: even cycles walk
667/// `l = 0 .. locnjob-2`, odd cycles walk it in reverse
668/// (`tditeration.c:1641-1648`). `athread` never does: it consumes
669/// `branchtable[0..nbranch]`, which is the identity permutation under the
670/// default `randomseed = 0` (`:522`, `:728-730`), so every cycle is a
671/// forward walk. This is what separated `--thread 1` from C by one gap
672/// column on `mtb_cds_120x1400.fa`.
673fn step_order(iter: usize, nsteps: usize, athread: bool) -> Vec<usize> {
674    if athread || iter % 2 == 0 {
675        (0..nsteps).collect()
676    } else {
677        (0..nsteps).rev().collect()
678    }
679}
680
681/// Re-align all sequences split into two groups.
682///
683/// Since group1 + group2 = ALL sequences, there are no "other" sequences
684/// to worry about. Every sequence is in exactly one group.
685///
686/// When `use_fft` is true (matching C's Falign path in tditeration.c):
687/// 1. Strip per-group gap columns → build stripped profiles
688/// 2. Run FFT anchor detection on stripped profiles (clean, residue-rich data)
689/// 3. Map anchors back to non-stripped coordinates via kept1/kept2
690/// 4. Build non-stripped profiles from full sequences
691/// 5. Run anchored DP on non-stripped profiles (matching C's input)
692///
693/// The anchor mapping ensures the FFT sees clean data for good anchor
694/// detection, while the DP operates on the same non-stripped profiles C
695/// uses. Anchors constrain the DP so width growth is bounded.
696///
697/// If FFT finds no anchors, falls back to profile_align on the full
698/// non-stripped sequences (matching C's single-segment fallback in
699/// Falign.c lines 1377-1421), bounded by alloclen.
700fn realign_all(
701    group1: &[usize],
702    group2: &[usize],
703    sequences: &[Vec<u8>],
704    weights: &[f64],
705    scoring: &ScoringContext,
706    gap: &GapModel,
707    constraints: Option<&LocalHomologyTable>,
708    use_fft: bool,
709    mm_input: Option<&MultiMtxInput>,
710    min_weight: f64,
711) -> Option<(Vec<Vec<u8>>, f64, Option<f64>)> {
712    let width = sequences[0].len();
713
714    // Per-group gap stripping.
715    let gap1 = group_all_gap_columns(group1, sequences, width);
716    let gap2 = group_all_gap_columns(group2, sequences, width);
717    let kept1: Vec<usize> = (0..width).filter(|&c| !gap1[c]).collect();
718    let kept2: Vec<usize> = (0..width).filter(|&c| !gap2[c]).collect();
719
720    // C clamps per-sequence weights to `minimumweight` (default 0.00001
721    // from `scripts/mafft:1029`, overridable via `--minimumweight`).
722    // Applied in `fastconjuction_noname` at `tddis.c:548`.
723    let w1: Vec<f64> = group1.iter().map(|&i| weights[i].max(min_weight)).collect();
724    let w2: Vec<f64> = group2.iter().map(|&i| weights[i].max(min_weight)).collect();
725    let sum1: f64 = w1.iter().sum();
726    let sum2: f64 = w2.iter().sum();
727    let w1n: Vec<f64> = if sum1 > 0.0 { w1.iter().map(|w| w / sum1).collect() } else { vec![1.0; group1.len()] };
728    let w2n: Vec<f64> = if sum2 > 0.0 { w2.iter().map(|w| w / sum2).collect() } else { vec![1.0; group2.len()] };
729
730    if let Ok(path) = std::env::var("RS_H_DUMP") {
731        if let Ok(shape) = std::env::var("RS_H_DUMP_SHAPE") {
732            let parts: Vec<&str> = shape.split(',').collect();
733            if parts.len() == 4 {
734                let sc1: usize = parts[2].parse().unwrap_or(0);
735                let sc2: usize = parts[3].parse().unwrap_or(0);
736                if group1.len() == sc1 && group2.len() == sc2 {
737                    use std::io::Write;
738                    if let Ok(mut fp) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
739                        let _ = write!(fp, "R_EFF1");
740                        for &w in &w1n { let _ = write!(fp, " {:.17e}", w); }
741                        let _ = writeln!(fp);
742                        let _ = write!(fp, "R_EFF2");
743                        for &w in &w2n { let _ = write!(fp, " {:.17e}", w); }
744                        let _ = writeln!(fp);
745                        let _ = write!(fp, "R_GROUP2_GLOBAL_IDX");
746                        for &g in group2 { let _ = write!(fp, " {}", g); }
747                        let _ = writeln!(fp);
748                        let _ = write!(fp, "R_WEIGHTS_RAW");
749                        for &g in group2 { let _ = write!(fp, " {:.17e}", weights[g]); }
750                        let _ = writeln!(fp);
751                    }
752                }
753            }
754        }
755    }
756
757    // Build stripped sequences and profiles (used for FFT anchor detection
758    // and as fallback for unconstrained non-FFT alignment).
759    let stripped1: Vec<Vec<u8>> = group1.iter()
760        .map(|&i| kept1.iter().map(|&c| sequences[i][c]).collect())
761        .collect();
762    let stripped2: Vec<Vec<u8>> = group2.iter()
763        .map(|&i| kept2.iter().map(|&c| sequences[i][c]).collect())
764        .collect();
765    let s1_refs: Vec<&[u8]> = stripped1.iter().map(|s| s.as_slice()).collect();
766    let s2_refs: Vec<&[u8]> = stripped2.iter().map(|s| s.as_slice()).collect();
767    let stripped_prof1 = Profile::from_aligned(&s1_refs, &w1n, &scoring.amino_map, scoring.nalphabets);
768    let stripped_prof2 = Profile::from_aligned(&s2_refs, &w2n, &scoring.amino_map, scoring.nalphabets);
769
770    if stripped_prof1.length == 0 || stripped_prof2.length == 0 {
771        return None;
772    }
773
774    if let Some(lh_table) = constraints {
775        if use_fft {
776            // C's `Falign_localhom` (kobetsubunkatsu=1 path): mirrors the
777            // FFT-segmented loop in the unconstrained `Falign` but calls
778            // `partA__align(constraint=1, ..., gapmap1, gapmap2, ...)` per
779            // segment. The global impmtx is built once for the full
780            // (non-stripped) alignment; each segment passes its sliced
781            // view via gapmap1/gapmap2 (which translate stripped column
782            // index to position within the segment).
783            return realign_all_constrained_fft(
784                group1, group2, sequences, &w1n, &w2n,
785                scoring, gap, lh_table, mm_input,
786            ).map(|(seqs, score, imp)| (seqs, score, Some(imp)));
787        }
788        // Non-FFT constraint path (L-INS-i without -F, single full DP).
789        // Mirrors C's `A__align(..., constraint=1, ...)` (Salignmm.c:1086):
790        // build the per-cell importance matrix `impmtx` once, then do the
791        // standard profile DP with `currentw[j] += impmtx[i][j]` applied
792        // row-by-row inside the DP (Salignmm.c:1700-1849).
793        let g1_seq_refs: Vec<&[u8]> = stripped1.iter().map(|s| s.as_slice()).collect();
794        let g2_seq_refs: Vec<&[u8]> = stripped2.iter().map(|s| s.as_slice()).collect();
795        let imp = build_imp_matrix(
796            lh_table,
797            group1, group2,
798            &g1_seq_refs, &g2_seq_refs,
799            &w1n, &w2n,
800            stripped_prof1.length, stripped_prof2.length,
801            FASTATHRESHOLD_DEFAULT,
802        );
803        let aln = profile_align_imp(
804            &stripped_prof1, &stripped_prof2,
805            &scoring.consweight_matrix,
806            gap,
807            true, true,
808            Some(&imp),
809        );
810        // Non-FFT constraint path: impmatch is folded into the DP score,
811        // not accumulated separately, so return None — caller falls back to
812        // `compute_impmatch_diagonal`. (dvtditr always passes -F, so this
813        // path is not exercised by the default L-INS-i pipeline.)
814        return build_result_from_stripped(
815            &aln, group1, group2, sequences, &kept1, &kept2,
816            &stripped_prof1, &stripped_prof2,
817        ).map(|(seqs, score)| (seqs, score, None));
818    }
819
820    if use_fft {
821        // C's Falign path for dvtditr (kobetsubunkatsu=1 in dvtditr.c:54):
822        // 1. SKIP the FFT block (Falign.c:1110 `if(!kobetsubunkatsu)` is skipped)
823        // 2. alignableReagion runs ONCE with lag=0 (Falign.c:1299 maxk=1,kouho[0]=0)
824        // 3. Collected segments define cut points: [0, center0, center1, ..., width]
825        // 4. Per segment, commongappick strips per-group all-gap columns
826        //    (Falign.c:1610 `if(kobetsubunkatsu && fftkeika)`)
827        // 5. MSalignmm aligns the stripped segment
828        // 6. Results concatenated
829        let width = sequences[0].len();
830        let full_prof1 = Profile::from_aligned(
831            &group1.iter().map(|&i| sequences[i].as_slice()).collect::<Vec<_>>(),
832            &w1n, &scoring.amino_map, scoring.nalphabets);
833        let full_prof2 = Profile::from_aligned(
834            &group2.iter().map(|&i| sequences[i].as_slice()).collect::<Vec<_>>(),
835            &w2n, &scoring.amino_map, scoring.nalphabets);
836
837        // mafft.tmpl passes `-z 50` to dvtditr, setting fftThreshold=50 for the
838        // alignableReagion sliding window (default from constants.c is 80, but
839        // the script overrides it). Match that here.
840        let segment_params = if scoring.seq_type.is_nucleotide() {
841            SegmentParams::dna().with_threshold(50.0)
842        } else {
843            SegmentParams::protein().with_threshold(50.0)
844        };
845
846        // Step 1: Compute per-position site scores at lag=0 (C's alignableReagion,
847        // fftFunctions.c:282-287). For refinement, prof1.length == prof2.length,
848        // so we score pairwise at matching columns. C uses n_disFFT which equals
849        // substitution_matrix when offset=0 (mafft default).
850        // C divides by totaleff = sum eff1[i]*eff2[j]; with normalized weights this is 1.
851        let totaleff: f64 = w1n.iter().sum::<f64>() * w2n.iter().sum::<f64>();
852        let len = full_prof1.length.min(full_prof2.length);
853        let mut site_scores = vec![0.0f64; len];
854        for i in 0..len {
855            site_scores[i] =
856                full_prof1.match_score(i, &full_prof2, i, &scoring.consweight_matrix)
857                / totaleff;
858        }
859
860        // Step 2: Find alignable segments via the sliding window threshold test.
861        let segments = alignable_segments(&site_scores, &segment_params);
862
863        // Step 3: Build cut points from segment centers (Falign.c:1414).
864        // kobetsubunkatsu=1: cut1[i+1] = sortedseg1[i]->center, plus [0] and [len].
865        // For refinement (lag=0), cut1[i] == cut2[i], so a single cut list suffices.
866        let mut cuts: Vec<usize> = Vec::with_capacity(segments.len() + 2);
867        cuts.push(0);
868        for seg in &segments {
869            cuts.push(seg.center.min(width));
870        }
871        cuts.push(width);
872        // Ensure strictly increasing (segments might overlap/coincide — dedupe).
873        cuts.sort();
874        cuts.dedup();
875
876        if let Ok(f) = std::env::var("RS_FFT_CUTS") {
877            use std::io::Write;
878            use std::sync::atomic::{AtomicUsize, Ordering};
879            static CALL_NO: AtomicUsize = AtomicUsize::new(0);
880            let cn = CALL_NO.fetch_add(1, Ordering::SeqCst);
881            if let Ok(mut fp) = std::fs::OpenOptions::new().create(true).append(true).open(&f) {
882                let cuts_str: Vec<String> = cuts.iter().map(|c| c.to_string()).collect();
883                let _ = writeln!(fp, "R_FALIGN call={} clus1={} clus2={} len={} nsegs={} cut={}",
884                    cn, group1.len(), group2.len(), width, cuts.len(), cuts_str.join(","));
885            }
886        }
887
888        // Step 4-5: Per-segment strip + align, then concatenate.
889        let mut new_sequences: Vec<Vec<u8>> = vec![Vec::new(); sequences.len()];
890        let mut total_score = 0.0f64;
891        for (seg_idx, win) in cuts.windows(2).enumerate() {
892            let a = win[0];
893            let b = win[1];
894            if a >= b { continue; }
895
896            // Slice the segment [a, b) from each sequence.
897            let seg1: Vec<Vec<u8>> = group1.iter()
898                .map(|&i| sequences[i][a..b].to_vec()).collect();
899            let seg2: Vec<Vec<u8>> = group2.iter()
900                .map(|&i| sequences[i][a..b].to_vec()).collect();
901
902            // commongappick on the segment: strip columns where all sequences in
903            // THIS GROUP have a gap within THIS SEGMENT. (C's commongappick)
904            let seg_width = b - a;
905            let seg_gap1: Vec<bool> = (0..seg_width)
906                .map(|c| seg1.iter().all(|s| s[c] == b'-')).collect();
907            let seg_gap2: Vec<bool> = (0..seg_width)
908                .map(|c| seg2.iter().all(|s| s[c] == b'-')).collect();
909            let seg_kept1: Vec<usize> = (0..seg_width).filter(|&c| !seg_gap1[c]).collect();
910            let seg_kept2: Vec<usize> = (0..seg_width).filter(|&c| !seg_gap2[c]).collect();
911
912            let stripped_seg1: Vec<Vec<u8>> = seg1.iter()
913                .map(|s| seg_kept1.iter().map(|&c| s[c]).collect()).collect();
914            let stripped_seg2: Vec<Vec<u8>> = seg2.iter()
915                .map(|s| seg_kept2.iter().map(|&c| s[c]).collect()).collect();
916
917            if stripped_seg1.is_empty() || stripped_seg1[0].is_empty() ||
918               stripped_seg2.is_empty() || stripped_seg2[0].is_empty() {
919                // One side is empty — emit gaps for both as-is (only possible
920                // when whole segment is all-gap for one group in every column).
921                let len1 = if stripped_seg1.is_empty() { 0 } else { stripped_seg1[0].len() };
922                let len2 = if stripped_seg2.is_empty() { 0 } else { stripped_seg2[0].len() };
923                for (k, &i) in group1.iter().enumerate() {
924                    new_sequences[i].extend_from_slice(&stripped_seg1[k]);
925                    new_sequences[i].extend(std::iter::repeat(b'-').take(len2));
926                }
927                for (k, &i) in group2.iter().enumerate() {
928                    new_sequences[i].extend(std::iter::repeat(b'-').take(len1));
929                    new_sequences[i].extend_from_slice(&stripped_seg2[k]);
930                }
931                continue;
932            }
933
934            let s1_refs: Vec<&[u8]> = stripped_seg1.iter().map(|s| s.as_slice()).collect();
935            let s2_refs: Vec<&[u8]> = stripped_seg2.iter().map(|s| s.as_slice()).collect();
936            if let Ok(f) = std::env::var("RS_FFT_CUTS") {
937                use std::io::Write;
938                if let Ok(mut fp) = std::fs::OpenOptions::new().create(true).append(true).open(&f) {
939                    let mut h1: u64 = 5381;
940                    let mut h2: u64 = 5381;
941                    for s in &stripped_seg1 { for &c in s { h1 = h1.wrapping_mul(33).wrapping_add(c as u64); } }
942                    for s in &stripped_seg2 { for &c in s { h2 = h2.wrapping_mul(33).wrapping_add(c as u64); } }
943                    let _ = writeln!(fp, "R_FSEG seg={} c1raw={} c2raw={} w1strip={} w2strip={} h1={:x} h2={:x}",
944                        seg_idx, b - a, b - a, stripped_seg1[0].len(), stripped_seg2[0].len(), h1, h2);
945                    for (j, s) in stripped_seg2.iter().enumerate() {
946                        let mut ph: u64 = 5381;
947                        for &c in s { ph = ph.wrapping_mul(33).wrapping_add(c as u64); }
948                        let first10: String = s.iter().take(10).map(|&c| c as char).collect();
949                        let _ = writeln!(fp, "  R_clus2[{}] len={} hash={:x} first10={}", j, s.len(), ph, first10);
950                    }
951                }
952            }
953            let mut prof_seg1 = Profile::from_aligned(&s1_refs, &w1n, &scoring.amino_map, scoring.nalphabets);
954            let mut prof_seg2 = Profile::from_aligned(&s2_refs, &w2n, &scoring.amino_map, scoring.nalphabets);
955
956            // C's Falign segment loop (lines 1549-1565):
957            //   sgap[j] = (cut1[i]  > 0)    ? (seq[j][cut1[i]-1]   == '-') : 'o'
958            //   egap[j] = (cut1[i+1] != len)? (seq[j][cut1[i+1]]   == '-') : 'o'
959            // These per-sequence boundary gap states are passed to MSalignmm,
960            // which switches from `st_OpeningGapCount` to `new_OpeningGapCount`
961            // (mltaln9.c:12557): a sequence already in a gap just before the
962            // segment's first column does NOT count as "opening" at position 0.
963            // `Profile::from_aligned` applies `st_OpeningGapCount` semantics
964            // (gc starts at 0), so we correct the first/last counts here.
965            let sgap_inside = a > 0;
966            if sgap_inside {
967                if !prof_seg1.ogcp.is_empty() {
968                    for (k, &idx) in group1.iter().enumerate() {
969                        if sequences[idx][a - 1] == b'-'
970                            && !stripped_seg1[k].is_empty()
971                            && stripped_seg1[k][0] == b'-'
972                        {
973                            prof_seg1.ogcp[0] -= w1n[k];
974                        }
975                    }
976                }
977                if !prof_seg2.ogcp.is_empty() {
978                    for (k, &idx) in group2.iter().enumerate() {
979                        if sequences[idx][a - 1] == b'-'
980                            && !stripped_seg2[k].is_empty()
981                            && stripped_seg2[k][0] == b'-'
982                        {
983                            prof_seg2.ogcp[0] -= w2n[k];
984                        }
985                    }
986                }
987            }
988            // NOTE: do NOT correct fgcp[last] for the egap boundary. C's
989            // `new_FinalGapCount` (mltaln9.c:12794-12828, the compiled
990            // `#if 1` branch) effectively ignores `egappat` at the last
991            // position — its post-loop block is dead code because the
992            // inner loop's final iteration reads the null terminator
993            // and sets gc=0, so `gb && !gc` never fires.
994            // `Profile::from_aligned`'s closing-tail increment already
995            // matches the resulting C value (= sum of weights for
996            // sequences ending in a gap). The earlier code that did
997            // `prof_seg.fgcp[last] -= wn[k]` mirrored the *disabled*
998            // `#if 0` C branch and over-subtracted at the segment tail.
999
1000            // C's Falign segment loop (lines 1521-1522):
1001            //   headgp = (i==0) ? outgap : 1   ;   tailgp = (i==count-2) ? outgap : 1
1002            // outgap=1 in dvtditr.c:73, so headgp=tailgp=1 for every segment.
1003            //
1004            // Boundary frequencies (Salignmm.c:1585-1622):
1005            //   outgapcount(headgapfreq, sgap, eff) is the weighted gap
1006            //   fraction at the segment's left/right boundary column in
1007            //   the parent, then inverted to non-gap fraction
1008            //   (legacygapcost=0). Used by the inner DP at `g_iskip` and
1009            //   head_gap initialization. `BoundaryFreqs::default() = 1.0`
1010            //   is correct only for the first segment (where sgap[*]='o'
1011            //   so outgapcount=0 → invert=1.0). Inner segments need the
1012            //   actual gap fraction at the parent's a-1 / b columns to
1013            //   match C bit-for-bit (BB30013 86-seq divergence cause).
1014            let outgap_count = |grp: &[usize], col: Option<usize>| -> f64 {
1015                match col {
1016                    None => 1.0,
1017                    Some(c) => {
1018                        let wn: &[f64] = if std::ptr::eq(grp.as_ptr(), group1.as_ptr()) { &w1n } else { &w2n };
1019                        let mut gap_frac = 0.0f64;
1020                        for (k, &idx) in grp.iter().enumerate() {
1021                            if sequences[idx][c] == b'-' { gap_frac += wn[k]; }
1022                        }
1023                        1.0 - gap_frac
1024                    }
1025                }
1026            };
1027            let left_col = if a > 0 { Some(a - 1) } else { None };
1028            let right_col = if b < width { Some(b) } else { None };
1029            let bf = BoundaryFreqs {
1030                head1: outgap_count(group1, left_col),
1031                head2: outgap_count(group2, left_col),
1032                tail1: outgap_count(group1, right_col),
1033                tail2: outgap_count(group2, right_col),
1034            };
1035            let seg_aln = profile_align_imp_with_boundary(
1036                &prof_seg1, &prof_seg2, &scoring.consweight_matrix, gap,
1037                true, true, None, false, bf,
1038            );
1039            total_score += seg_aln.score;
1040
1041            // Reconstruct the segment's output by applying ops to the stripped segments.
1042            let mut i1 = 0usize;
1043            let mut i2 = 0usize;
1044            for op in &seg_aln.operations {
1045                match op {
1046                    AlignOp::Match => {
1047                        for (k, &idx) in group1.iter().enumerate() {
1048                            new_sequences[idx].push(stripped_seg1[k][i1]);
1049                        }
1050                        for (k, &idx) in group2.iter().enumerate() {
1051                            new_sequences[idx].push(stripped_seg2[k][i2]);
1052                        }
1053                        i1 += 1;
1054                        i2 += 1;
1055                    }
1056                    AlignOp::Delete => {
1057                        for (k, &idx) in group1.iter().enumerate() {
1058                            new_sequences[idx].push(stripped_seg1[k][i1]);
1059                        }
1060                        for &idx in group2 {
1061                            new_sequences[idx].push(b'-');
1062                        }
1063                        i1 += 1;
1064                    }
1065                    AlignOp::Insert => {
1066                        for &idx in group1 {
1067                            new_sequences[idx].push(b'-');
1068                        }
1069                        for (k, &idx) in group2.iter().enumerate() {
1070                            new_sequences[idx].push(stripped_seg2[k][i2]);
1071                        }
1072                        i2 += 1;
1073                    }
1074                }
1075            }
1076        }
1077
1078        return Some((new_sequences, total_score, None));
1079    }
1080
1081    // Non-FFT path: profile_align on stripped profiles.
1082    let aln = profile_align(
1083        &stripped_prof1, &stripped_prof2,
1084        &scoring.consweight_matrix, gap, true, true,
1085    );
1086    build_result_from_stripped(
1087        &aln, group1, group2, sequences, &kept1, &kept2,
1088        &stripped_prof1, &stripped_prof2,
1089    ).map(|(seqs, score)| (seqs, score, None))
1090}
1091
1092/// Constraint-aware FFT-segmented refinement, port of C's
1093/// `Falign_localhom` (Falign_localhom.c:163, kobetsubunkatsu=1 path).
1094///
1095/// Mirrors the unconstrained FFT-segmented refinement in
1096/// `realign_all`'s `use_fft` branch but per-segment calls
1097/// `profile_align_imp` with the local impmtx slice rather than the
1098/// unconstrained `profile_align`. The impmtx is built once for the full
1099/// alignment width using the localhom regions; each segment's local
1100/// view is the rectangle covering the segment's parent column range,
1101/// indexed by the per-group strip kept-column lists (= C's `gapmap1` /
1102/// `gapmap2`).
1103///
1104/// The cut points are computed from `alignable_segments` at lag=0 just
1105/// like the unconstrained path (C's `alignableReagion` with maxk=1 in
1106/// kobetsubunkatsu mode).
1107fn realign_all_constrained_fft(
1108    group1: &[usize],
1109    group2: &[usize],
1110    sequences: &[Vec<u8>],
1111    w1n: &[f64],
1112    w2n: &[f64],
1113    scoring: &ScoringContext,
1114    gap: &GapModel,
1115    lh_table: &LocalHomologyTable,
1116    mm_input: Option<&MultiMtxInput>,
1117) -> Option<(Vec<Vec<u8>>, f64, f64)> {
1118    let width = sequences[group1[0]].len();
1119    if width == 0 { return None; }
1120
1121    // Build full-alignment profiles for site-score / segment detection
1122    // (matches the unconstrained FFT path). For refinement,
1123    // prof1.length == prof2.length == width.
1124    let full_prof1 = Profile::from_aligned(
1125        &group1.iter().map(|&i| sequences[i].as_slice()).collect::<Vec<_>>(),
1126        w1n, &scoring.amino_map, scoring.nalphabets,
1127    );
1128    let full_prof2 = Profile::from_aligned(
1129        &group2.iter().map(|&i| sequences[i].as_slice()).collect::<Vec<_>>(),
1130        w2n, &scoring.amino_map, scoring.nalphabets,
1131    );
1132
1133    let segment_params = if scoring.seq_type.is_nucleotide() {
1134        SegmentParams::dna().with_threshold(50.0)
1135    } else {
1136        SegmentParams::protein().with_threshold(50.0)
1137    };
1138
1139    // Per-position site scores at lag=0 (C's alignableReagion).
1140    let totaleff: f64 = w1n.iter().sum::<f64>() * w2n.iter().sum::<f64>();
1141    let len = full_prof1.length.min(full_prof2.length);
1142    let mut site_scores = vec![0.0f64; len];
1143    for i in 0..len {
1144        site_scores[i] = full_prof1.match_score(
1145            i, &full_prof2, i, &scoring.consweight_matrix,
1146        ) / totaleff;
1147    }
1148    let segments = alignable_segments(&site_scores, &segment_params);
1149
1150    // Cut points (C's `cut1[i+1] = sortedseg1[i]->center` plus 0 and len).
1151    let mut cuts: Vec<usize> = Vec::with_capacity(segments.len() + 2);
1152    cuts.push(0);
1153    for seg in &segments { cuts.push(seg.center.min(width)); }
1154    cuts.push(width);
1155    cuts.sort();
1156    cuts.dedup();
1157
1158    // Build the GLOBAL impmtx for the full non-stripped alignment
1159    // (width × width). C does this via `part_imp_match_init_strict(...,
1160    // length, length, mseq1, mseq2, ...)` once per branch realign.
1161    // Each per-segment partA__align then reads `impmtx[start1+gapmap1[i]][start2+gapmap2[j]]`.
1162    let g1_full: Vec<&[u8]> = group1.iter().map(|&i| sequences[i].as_slice()).collect();
1163    let g2_full: Vec<&[u8]> = group2.iter().map(|&i| sequences[i].as_slice()).collect();
1164    let global_imp = build_imp_matrix(
1165        lh_table,
1166        group1, group2,
1167        &g1_full, &g2_full,
1168        w1n, w2n,
1169        width, width,
1170        FASTATHRESHOLD_DEFAULT,
1171    );
1172
1173    // `--allowshift`: precompute this branch's multi-distance-class context
1174    // (C `makescoringmatrices` + `classifypairs` + masklists). Class
1175    // assignment uses member-level tree distances `distarr[group{1,2}[·]]`,
1176    // which are constant across FFT segments; only the per-segment cpmx
1177    // profiles differ, so this is built once here.
1178    let mm_ctx: Option<MmBranchCtx> = mm_input.map(|mi| {
1179        let n1 = group1.len();
1180        let n2 = group2.len();
1181        let max_dc = crate::varidist::calc_max_dist_class(mi.unalign_level);
1182        let gap_idx = scoring.amino_map[b'-' as usize] as usize;
1183        let matrices = crate::varidist::make_scoring_matrices(
1184            &scoring.consweight_matrix, mi.unalign_level, gap_idx, max_dc,
1185        );
1186        // smalldist[i][j] = distFromABranch(group1[i]) + distFromABranch(group2[j])
1187        // (C `OneClusterAndTheOther_fast` with `USEDISTONTREE = 1`).
1188        let smalldist: Vec<Vec<f64>> = (0..n1)
1189            .map(|i| (0..n2).map(|j| mi.distarr[group1[i]] + mi.distarr[group2[j]]).collect())
1190            .collect();
1191        let pc = crate::varidist::classify_pairs(w1n, w2n, &smalldist, max_dc);
1192        // Spurious-pair masks: pairs landing in class c's cpmx product whose
1193        // true class differs (subtracted by `match_calc_del`). i-major, j-minor.
1194        let mut mask1 = vec![Vec::new(); max_dc];
1195        let mut mask2 = vec![Vec::new(); max_dc];
1196        for c in 0..max_dc {
1197            for i in 0..n1 {
1198                for j in 0..n2 {
1199                    if pc.eff1s[c][i] * pc.eff2s[c][j] != 0.0 && c != pc.matnum[i][j] {
1200                        mask1[c].push(i);
1201                        mask2[c].push(j);
1202                    }
1203                }
1204            }
1205        }
1206        MmBranchCtx { matrices, eff1s: pc.eff1s, eff2s: pc.eff2s, mask1, mask2 }
1207    });
1208
1209    let mut new_sequences: Vec<Vec<u8>> = vec![Vec::new(); sequences.len()];
1210    let mut total_score = 0.0f64;
1211    // C `Falign_localhom.c:816`: `*totalimpmatch += impmatch` per FFT
1212    // segment, summed forward across segments. Within each segment C's
1213    // `Atracking_localhom` accumulates `impmtx` at match cells in BACKWARD
1214    // traceback order. We reproduce that exact FP order here so `new_imp`
1215    // matches C bit-for-equivalent (closes BB30028; a global diagonal sum
1216    // diverges by ~7 ULP because it merges all segments into one sweep).
1217    let mut total_impmatch = 0.0f64;
1218    for win in cuts.windows(2) {
1219        let a = win[0];
1220        let b = win[1];
1221        if a >= b { continue; }
1222        let seg_width = b - a;
1223
1224        // Slice [a, b) from each member.
1225        let seg1: Vec<Vec<u8>> = group1.iter()
1226            .map(|&i| sequences[i][a..b].to_vec()).collect();
1227        let seg2: Vec<Vec<u8>> = group2.iter()
1228            .map(|&i| sequences[i][a..b].to_vec()).collect();
1229
1230        // commongappick within segment + record gapmap (= column index
1231        // within segment for each kept stripped column).
1232        let seg_gap1: Vec<bool> = (0..seg_width)
1233            .map(|c| seg1.iter().all(|s| s[c] == b'-')).collect();
1234        let seg_gap2: Vec<bool> = (0..seg_width)
1235            .map(|c| seg2.iter().all(|s| s[c] == b'-')).collect();
1236        let gapmap1: Vec<usize> = (0..seg_width).filter(|&c| !seg_gap1[c]).collect();
1237        let gapmap2: Vec<usize> = (0..seg_width).filter(|&c| !seg_gap2[c]).collect();
1238
1239        let stripped_seg1: Vec<Vec<u8>> = seg1.iter()
1240            .map(|s| gapmap1.iter().map(|&c| s[c]).collect()).collect();
1241        let stripped_seg2: Vec<Vec<u8>> = seg2.iter()
1242            .map(|s| gapmap2.iter().map(|&c| s[c]).collect()).collect();
1243
1244        // Edge case: one side completely empty after strip.
1245        if stripped_seg1.is_empty() || stripped_seg1[0].is_empty()
1246            || stripped_seg2.is_empty() || stripped_seg2[0].is_empty()
1247        {
1248            let len1 = if stripped_seg1.is_empty() { 0 } else { stripped_seg1[0].len() };
1249            let len2 = if stripped_seg2.is_empty() { 0 } else { stripped_seg2[0].len() };
1250            for (k, &i) in group1.iter().enumerate() {
1251                new_sequences[i].extend_from_slice(&stripped_seg1[k]);
1252                new_sequences[i].extend(std::iter::repeat(b'-').take(len2));
1253            }
1254            for (k, &i) in group2.iter().enumerate() {
1255                new_sequences[i].extend(std::iter::repeat(b'-').take(len1));
1256                new_sequences[i].extend_from_slice(&stripped_seg2[k]);
1257            }
1258            continue;
1259        }
1260
1261        // Build the segment's local impmtx by indexing the global impmtx
1262        // at (a + gapmap1[i], a + gapmap2[j]) — mirrors C's
1263        // `imp_match_out_vead_gapmap(imp[j] = impmtx[i1][start2+gapmap2[j]])`
1264        // (partSalignmm.c:71-83). For refinement, start1 = start2 = a.
1265        let l1 = stripped_seg1[0].len();
1266        let l2 = stripped_seg2[0].len();
1267        let mut local_imp = vec![vec![0.0f64; l2]; l1];
1268        for i in 0..l1 {
1269            let row = a + gapmap1[i];
1270            for j in 0..l2 {
1271                let col = a + gapmap2[j];
1272                if row < global_imp.len() && col < global_imp[row].len() {
1273                    local_imp[i][j] = global_imp[row][col];
1274                }
1275            }
1276        }
1277
1278        let s1_refs: Vec<&[u8]> = stripped_seg1.iter().map(|s| s.as_slice()).collect();
1279        let s2_refs: Vec<&[u8]> = stripped_seg2.iter().map(|s| s.as_slice()).collect();
1280        let mut prof_seg1 = Profile::from_aligned(&s1_refs, w1n, &scoring.amino_map, scoring.nalphabets);
1281        let mut prof_seg2 = Profile::from_aligned(&s2_refs, w2n, &scoring.amino_map, scoring.nalphabets);
1282
1283        // Per-segment boundary gap correction (mirrors C's `getkyokaigap`
1284        // → `new_OpeningGapCount` in `MSalignmm`/`partA__align`). Same as
1285        // the unconstrained FFT path: a sequence already in a gap just
1286        // before the segment's first column does NOT count as opening at
1287        // position 0.
1288        let sgap_inside = a > 0;
1289        let egap_inside = b < width;
1290        if sgap_inside || egap_inside {
1291            if !prof_seg1.ogcp.is_empty() {
1292                for (k, &idx) in group1.iter().enumerate() {
1293                    if sgap_inside
1294                        && sequences[idx][a - 1] == b'-'
1295                        && !stripped_seg1[k].is_empty()
1296                        && stripped_seg1[k][0] == b'-'
1297                    {
1298                        prof_seg1.ogcp[0] -= w1n[k];
1299                    }
1300                    let l1k = stripped_seg1[k].len();
1301                    if egap_inside
1302                        && l1k > 0
1303                        && stripped_seg1[k][l1k - 1] == b'-'
1304                        && sequences[idx][b] == b'-'
1305                    {
1306                        let last = prof_seg1.fgcp.len() - 1;
1307                        prof_seg1.fgcp[last] -= w1n[k];
1308                    }
1309                }
1310            }
1311            if !prof_seg2.ogcp.is_empty() {
1312                for (k, &idx) in group2.iter().enumerate() {
1313                    if sgap_inside
1314                        && sequences[idx][a - 1] == b'-'
1315                        && !stripped_seg2[k].is_empty()
1316                        && stripped_seg2[k][0] == b'-'
1317                    {
1318                        prof_seg2.ogcp[0] -= w2n[k];
1319                    }
1320                    let l2k = stripped_seg2[k].len();
1321                    if egap_inside
1322                        && l2k > 0
1323                        && stripped_seg2[k][l2k - 1] == b'-'
1324                        && sequences[idx][b] == b'-'
1325                    {
1326                        let last = prof_seg2.fgcp.len() - 1;
1327                        prof_seg2.fgcp[last] -= w2n[k];
1328                    }
1329                }
1330            }
1331        }
1332
1333        // C's Falign_localhom segment loop passes `headgp = tailgp = 1`
1334        // when a segment is interior (`(i==0)?outgap:1` etc.); for
1335        // dvtditr, outgap=1 anyway so all segments use 1.
1336        //
1337        // C uses `partA__align` (partSalignmm.c:1218,1235) which uses STRICT
1338        // `>` for the prept-vs-mi/mjpt tie-break (the "// 2018/Apr" change).
1339        // The progressive `A__align` uses `>=`. We pass strict_part_tiebreak=true
1340        // to match `partA__align` exactly.
1341        //
1342        // Boundary nongap-frequencies (C's headgapfreq{1,2} and
1343        // gapfreq{1,2}[lgth] computed via outgapcount on sgap/egap):
1344        //   head{1,2} = nongap fraction at full-alignment column [a-1]
1345        //               (1.0 when a == 0 → C's `sgap[j]='o'` branch)
1346        //   tail{1,2} = nongap fraction at full-alignment column [b]
1347        //               (1.0 when b == width → C's `egap[j]='o'` branch)
1348        let head1 = if a > 0 {
1349            let s: f64 = group1.iter().enumerate()
1350                .filter(|&(_, &idx)| sequences[idx][a - 1] == b'-')
1351                .map(|(k, _)| w1n[k]).sum();
1352            1.0 - s
1353        } else { 1.0 };
1354        let head2 = if a > 0 {
1355            let s: f64 = group2.iter().enumerate()
1356                .filter(|&(_, &idx)| sequences[idx][a - 1] == b'-')
1357                .map(|(k, _)| w2n[k]).sum();
1358            1.0 - s
1359        } else { 1.0 };
1360        let tail1 = if b < width {
1361            let s: f64 = group1.iter().enumerate()
1362                .filter(|&(_, &idx)| sequences[idx][b] == b'-')
1363                .map(|(k, _)| w1n[k]).sum();
1364            1.0 - s
1365        } else { 1.0 };
1366        let tail2 = if b < width {
1367            let s: f64 = group2.iter().enumerate()
1368                .filter(|&(_, &idx)| sequences[idx][b] == b'-')
1369                .map(|(k, _)| w2n[k]).sum();
1370            1.0 - s
1371        } else { 1.0 };
1372        let boundary = BoundaryFreqs { head1, head2, tail1, tail2 };
1373        let seg_aln = if let Some(ctx) = mm_ctx.as_ref() {
1374            // `--allowshift`: build this segment's per-class cpmx profiles
1375            // (weighted by eff{1,2}s[c], same accumulation as the single
1376            // matrix Profile so the c=0 class is bit-identical), then run
1377            // the multi-distance-class DP (C `partA__align_variousdist`).
1378            let nc = ctx.matrices.len();
1379            let cpmx1s: Vec<Vec<Vec<f64>>> = (0..nc)
1380                .map(|c| Profile::from_aligned(
1381                    &s1_refs, &ctx.eff1s[c], &scoring.amino_map, scoring.nalphabets,
1382                ).freqs)
1383                .collect();
1384            let cpmx2s: Vec<Vec<Vec<f64>>> = (0..nc)
1385                .map(|c| Profile::from_aligned(
1386                    &s2_refs, &ctx.eff2s[c], &scoring.amino_map, scoring.nalphabets,
1387                ).freqs)
1388                .collect();
1389            // Sparse (alpha_index, value) representations of cpmx1s/cpmx2s,
1390            // skipping zero alphabet positions. Hot path for
1391            // `MultiMtx::match_row_into` — for 1-residue clusters this turns
1392            // an O(nalpha²) scarr build + O(nalpha·lgth2) accumulation into
1393            // O(nalpha) + O(lgth2). nalpha < 256 so u8 indices suffice.
1394            let sparsify = |dense: &Vec<Vec<Vec<f64>>>| -> Vec<Vec<Vec<(u8, f64)>>> {
1395                dense.iter().map(|class| {
1396                    class.iter().map(|col| {
1397                        let mut v: Vec<(u8, f64)> = Vec::with_capacity(col.len());
1398                        for (l, &x) in col.iter().enumerate() {
1399                            if x != 0.0 { v.push((l as u8, x)); }
1400                        }
1401                        v
1402                    }).collect()
1403                }).collect()
1404            };
1405            let cpmx1s_sparse = sparsify(&cpmx1s);
1406            let cpmx2s_sparse = sparsify(&cpmx2s);
1407            let mm = MultiMtx {
1408                matrices: &ctx.matrices,
1409                cpmx1s: &cpmx1s,
1410                cpmx2s: &cpmx2s,
1411                cpmx1s_sparse: &cpmx1s_sparse,
1412                cpmx2s_sparse: &cpmx2s_sparse,
1413                mask1: &ctx.mask1,
1414                mask2: &ctx.mask2,
1415                seq1: &s1_refs,
1416                seq2: &s2_refs,
1417                eff1: w1n,
1418                eff2: w2n,
1419                amino_map: &scoring.amino_map,
1420                nalpha: scoring.nalphabets,
1421            };
1422            profile_align_imp_multimtx(
1423                &prof_seg1, &prof_seg2, &scoring.consweight_matrix, gap,
1424                true, true, Some(&local_imp), true, boundary, Some(&mm),
1425            )
1426        } else {
1427            profile_align_imp_with_boundary(
1428                &prof_seg1, &prof_seg2, &scoring.consweight_matrix, gap,
1429                true, true, Some(&local_imp), true, boundary,
1430            )
1431        };
1432        total_score += seg_aln.score;
1433
1434        // Per-segment impmatch: C's `Atracking_localhom` (Dalignmm.c:564)
1435        // adds `impmtx[iin][jin]` at each match cell during the BACKWARD
1436        // traceback. Collect this segment's match cells (stripped-local
1437        // positions) and sum `local_imp` over them in reverse op order to
1438        // reproduce that summation order; then add to `total_impmatch`
1439        // (forward across segments, matching Falign_localhom.c:816).
1440        {
1441            let mut mi1 = 0usize;
1442            let mut mi2 = 0usize;
1443            let mut match_cells: Vec<(usize, usize)> = Vec::new();
1444            for op in &seg_aln.operations {
1445                match op {
1446                    AlignOp::Match => { match_cells.push((mi1, mi2)); mi1 += 1; mi2 += 1; }
1447                    AlignOp::Delete => { mi1 += 1; }
1448                    AlignOp::Insert => { mi2 += 1; }
1449                }
1450            }
1451            let mut seg_imp = 0.0f64;
1452            for &(ci, cj) in match_cells.iter().rev() {
1453                seg_imp += local_imp[ci][cj];
1454            }
1455            total_impmatch += seg_imp;
1456        }
1457
1458        // Reconstruct segment output by applying ops to stripped segments.
1459        let mut i1 = 0usize;
1460        let mut i2 = 0usize;
1461        for op in &seg_aln.operations {
1462            match op {
1463                AlignOp::Match => {
1464                    for (k, &idx) in group1.iter().enumerate() {
1465                        new_sequences[idx].push(stripped_seg1[k][i1]);
1466                    }
1467                    for (k, &idx) in group2.iter().enumerate() {
1468                        new_sequences[idx].push(stripped_seg2[k][i2]);
1469                    }
1470                    i1 += 1; i2 += 1;
1471                }
1472                AlignOp::Delete => {
1473                    for (k, &idx) in group1.iter().enumerate() {
1474                        new_sequences[idx].push(stripped_seg1[k][i1]);
1475                    }
1476                    for &idx in group2 {
1477                        new_sequences[idx].push(b'-');
1478                    }
1479                    i1 += 1;
1480                }
1481                AlignOp::Insert => {
1482                    for &idx in group1 {
1483                        new_sequences[idx].push(b'-');
1484                    }
1485                    for (k, &idx) in group2.iter().enumerate() {
1486                        new_sequences[idx].push(stripped_seg2[k][i2]);
1487                    }
1488                    i2 += 1;
1489                }
1490            }
1491        }
1492    }
1493
1494    // Pad sequences not in either group to the new width.
1495    let new_width = if !group1.is_empty() {
1496        new_sequences[group1[0]].len()
1497    } else if !group2.is_empty() {
1498        new_sequences[group2[0]].len()
1499    } else {
1500        width
1501    };
1502    for (i, s) in new_sequences.iter_mut().enumerate() {
1503        if !group1.contains(&i) && !group2.contains(&i) {
1504            // "Other" sequences — preserve from input. Our refinement only
1505            // realigns groups that partition all sequences, so this should
1506            // never trigger; keep original.
1507            *s = sequences[i].clone();
1508        }
1509        if s.len() < new_width {
1510            s.resize(new_width, b'-');
1511        }
1512    }
1513
1514    Some((new_sequences, total_score, total_impmatch))
1515}
1516
1517/// Build result sequences from an alignment on stripped profiles.
1518/// Maps alignment operations back to original column positions via kept1/kept2.
1519fn build_result_from_stripped(
1520    aln: &mafft_align::Alignment,
1521    group1: &[usize],
1522    group2: &[usize],
1523    sequences: &[Vec<u8>],
1524    kept1: &[usize],
1525    kept2: &[usize],
1526    prof1: &Profile,
1527    prof2: &Profile,
1528) -> Option<(Vec<Vec<u8>>, f64)> {
1529    let consumed1 = aln.operations.iter()
1530        .filter(|op| matches!(op, AlignOp::Match | AlignOp::Delete)).count();
1531    let consumed2 = aln.operations.iter()
1532        .filter(|op| matches!(op, AlignOp::Match | AlignOp::Insert)).count();
1533    if consumed1 != prof1.length || consumed2 != prof2.length {
1534        return None;
1535    }
1536
1537    let mut new_sequences = vec![Vec::with_capacity(aln.operations.len()); sequences.len()];
1538    let mut c1 = 0usize;
1539    let mut c2 = 0usize;
1540    for op in &aln.operations {
1541        match op {
1542            AlignOp::Match => {
1543                let oc1 = kept1[c1];
1544                let oc2 = kept2[c2];
1545                for &i in group1 { new_sequences[i].push(sequences[i][oc1]); }
1546                for &i in group2 { new_sequences[i].push(sequences[i][oc2]); }
1547                c1 += 1; c2 += 1;
1548            }
1549            AlignOp::Delete => {
1550                let oc1 = kept1[c1];
1551                for &i in group1 { new_sequences[i].push(sequences[i][oc1]); }
1552                for &i in group2 { new_sequences[i].push(b'-'); }
1553                c1 += 1;
1554            }
1555            AlignOp::Insert => {
1556                let oc2 = kept2[c2];
1557                for &i in group1 { new_sequences[i].push(b'-'); }
1558                for &i in group2 { new_sequences[i].push(sequences[i][oc2]); }
1559                c2 += 1;
1560            }
1561        }
1562    }
1563    Some((new_sequences, aln.score))
1564}
1565
1566fn group_all_gap_columns(group: &[usize], sequences: &[Vec<u8>], width: usize) -> Vec<bool> {
1567    // Match C's commongappick: only '-' counts as gap (not '.').
1568    let mut all_gap = vec![true; width];
1569    for &idx in group {
1570        for (col, &ch) in sequences[idx].iter().enumerate() {
1571            if ch != b'-' {
1572                all_gap[col] = false;
1573            }
1574        }
1575    }
1576    all_gap
1577}
1578
1579/// Sum the impmatch (constraint-importance bonus) across the diagonal of
1580/// the current alignment. Mirrors C's `oimpmatchdouble = sum imp_match_out_sc(i,i)`
1581/// loop (tditeration.c:925) for the existing alignment's `impmtx`.
1582///
1583/// `eff1`, `eff2` are group-local sum-1-normalized weights matching
1584/// what `imp_match_init_strict` is called with in C (= `effarr1`/`effarr2`
1585/// after `fastconjuction_noname` per-cluster normalization).
1586fn compute_impmatch_diagonal(
1587    group1: &[usize],
1588    group2: &[usize],
1589    sequences: &[Vec<u8>],
1590    eff1: &[f64],
1591    eff2: &[f64],
1592    lh_table: &LocalHomologyTable,
1593) -> f64 {
1594    let width = sequences[group1[0]].len();
1595    if width == 0 { return 0.0; }
1596    let g1_seqs: Vec<&[u8]> = group1.iter().map(|&i| sequences[i].as_slice()).collect();
1597    let g2_seqs: Vec<&[u8]> = group2.iter().map(|&i| sequences[i].as_slice()).collect();
1598    let imp = build_imp_matrix(
1599        lh_table,
1600        group1, group2,
1601        &g1_seqs, &g2_seqs,
1602        eff1, eff2,
1603        width, width,
1604        FASTATHRESHOLD_DEFAULT,
1605    );
1606    // C `tditeration.c:891`: `for(i=length-1; i>=0; i--) oimpmatchdouble += imp_match_out_scD(i,i);`
1607    // — sums BACKWARD. Match the iteration direction; FP addition is not
1608    // associative, and forward summation drifts by ~1 ULP per step, which
1609    // cascades into a flipped accept/reject at BB20004 iter=4 l=36 k=1.
1610    let mut total = 0.0f64;
1611    for i in (0..width).rev() {
1612        if let Some(row) = imp.get(i) {
1613            if let Some(&v) = row.get(i) {
1614                total += v;
1615            }
1616        }
1617    }
1618    total
1619}
1620
1621fn compute_split_score(
1622    group1: &[usize],
1623    group2: &[usize],
1624    sequences: &[Vec<u8>],
1625    weights: &[f64],
1626    scoring: &ScoringContext,
1627    min_weight: f64,
1628) -> f64 {
1629    // Port of C's intergroup_score flow: weights are per-group normalized
1630    // by fastconjuction_noname (tddis.c line 548) before being passed.
1631    // Apply the same normalization here: each group's weights sum to 1.0.
1632    // `min_weight` is C's `minimumweight` (default 0.00001, overridable
1633    // via `--minimumweight`).
1634    let w1: Vec<f64> = group1.iter().map(|&i| weights[i].max(min_weight)).collect();
1635    let w2: Vec<f64> = group2.iter().map(|&i| weights[i].max(min_weight)).collect();
1636    let s1: f64 = w1.iter().sum();
1637    let s2: f64 = w2.iter().sum();
1638    let w1n: Vec<f64> = if s1 > 0.0 { w1.iter().map(|w| w / s1).collect() } else { vec![1.0; group1.len()] };
1639    let w2n: Vec<f64> = if s2 > 0.0 { w2.iter().map(|w| w / s2).collect() } else { vec![1.0; group2.len()] };
1640
1641    // Sequential sum to match C's deterministic accumulation order.
1642    // par_iter gives non-deterministic summation order, which causes
1643    // small FP divergence that cascades into accept/reject decisions.
1644    //
1645    // FMA: clang at -O3 with FP_CONTRACT=on lowers
1646    //   total += score * wi * wj
1647    // to one plain mul (score * wi) plus one fma (acc += (score*wi)*wj),
1648    // i.e. 2 roundings. Plain Rust `*` and `+=` give 3 roundings, and
1649    // the resulting sub-ULP per-pair drift accumulates over (clus1 *
1650    // clus2) pairs into a multi-unit mscore drift that flips
1651    // accept/reject decisions late in iterative refinement (BB30028
1652    // L-INS-i iter=3 l=5 k=1 fingerprint).
1653    let mut total = 0.0f64;
1654    for (i_local, &i) in group1.iter().enumerate() {
1655        let wi = w1n[i_local];
1656        for (j_local, &j) in group2.iter().enumerate() {
1657            let wj = w2n[j_local];
1658            let s_wi = pairwise_score(&sequences[i], &sequences[j], scoring) * wi;
1659            total = fmadd(s_wi, wj, total);
1660        }
1661    }
1662    total
1663}
1664
1665/// Branchless pairwise scoring for auto-vectorization.
1666///
1667/// The gap check is converted to a mask multiply: if either residue is '-',
1668/// the score contribution is 0. This eliminates branches that prevent SIMD.
1669#[inline]
1670fn pairwise_score(seq1: &[u8], seq2: &[u8], scoring: &ScoringContext) -> f64 {
1671    let map = &scoring.amino_map;
1672    let mtx = &scoring.consweight_matrix;
1673    let mtx_size = mtx.len();
1674
1675    // Port of C's intergroup_score (mltaln9.c lines 404-475):
1676    // - Gap-gap positions: skipped (continue).
1677    // - Match positions: add amino_dis[c1][c2].
1678    // - Gap in seq1: add `penalty` (gap open), then consume all consecutive
1679    //   '-' in seq1. Same for seq2.
1680    // - amino_dis_consweight_multi[gap][*] = 0, so gap-region positions
1681    //   contribute only the single gap-open penalty per gap run.
1682    //
1683    // A previous attempt to rewrite this as a per-cell branchless state
1684    // machine (2026-05-18) failed because C's `while (seq1[k] == '-')`
1685    // consume loop crosses both-gap positions (it only looks at seq1),
1686    // while a naive per-cell rule that resets state on both-gap would
1687    // re-charge penalty when an A-gap-run is interrupted by a both-gap
1688    // column. The minimum branchless formulation that matches C exactly
1689    // is a 3-state machine (Neutral / AGapRun / BGapRun) — still has
1690    // branches, no clean SIMD path. Keeping the C-style structure.
1691    let penalty = scoring.gap.open as f64;
1692    let len = seq1.len().min(seq2.len());
1693    let mut score = 0.0f64;
1694    let mut k = 0;
1695    while k < len {
1696        let a = seq1[k];
1697        let b = seq2[k];
1698        if a == b'-' && b == b'-' {
1699            k += 1;
1700            continue;
1701        }
1702        if a == b'-' {
1703            score += penalty;
1704            // Consume all consecutive gaps in seq1 (C's while-loop at line 448).
1705            k += 1;
1706            while k < len && seq1[k] == b'-' {
1707                k += 1;
1708            }
1709            continue;
1710        }
1711        if b == b'-' {
1712            score += penalty;
1713            k += 1;
1714            while k < len && seq2[k] == b'-' {
1715                k += 1;
1716            }
1717            continue;
1718        }
1719        let i = map[a as usize] as usize;
1720        let j = map[b as usize] as usize;
1721        if i < mtx_size && j < mtx_size {
1722            score += mtx[i][j];
1723        }
1724        k += 1;
1725    }
1726    score
1727}
1728
1729/// Find anchor column positions for segmenting a multiple alignment, mirroring
1730/// C `searchAnchors` (`mltaln9.c:11318`).
1731///
1732/// For each column, computes the average pairwise substitution score. Slides a
1733/// window of `div_win_size` (=20) columns; where the windowed sum exceeds
1734/// `div_threshold_pct/100 * 600 * div_win_size` (=7800 by default), marks an
1735/// anchor region. Returns the centers of those regions, framed by `[0, len]`
1736/// so consecutive entries define the segment boundaries dvtditr.c:1085 uses.
1737fn search_anchors_aa(
1738    sequences: &[Vec<u8>],
1739    matrix: &[Vec<i32>],
1740    amino_map: &[u8; 256],
1741    div_win_size: usize,
1742    div_threshold_pct: i32,
1743) -> Vec<usize> {
1744    const SEGMENTSIZE: usize = 150;
1745
1746    let nseq = sequences.len();
1747    let len = sequences.first().map_or(0, |s| s.len());
1748    if nseq < 2 || len < div_win_size + 2 {
1749        return vec![0, len];
1750    }
1751    let threshold = (div_threshold_pct as f64 / 100.0) * 600.0 * div_win_size as f64;
1752    let n_pairs = (nseq * (nseq - 1) / 2) as f64;
1753    let mtx_size = matrix.len();
1754
1755    // Per-column average pairwise substitution score (stra[i] in C).
1756    let mut stra = vec![0.0f64; len];
1757    for i in 0..len {
1758        let mut sum = 0.0f64;
1759        for k in 0..(nseq - 1) {
1760            let ki = amino_map[sequences[k][i] as usize] as usize;
1761            for j in (k + 1)..nseq {
1762                let ji = amino_map[sequences[j][i] as usize] as usize;
1763                if ki < mtx_size && ji < mtx_size {
1764                    sum += matrix[ki][ji] as f64;
1765                }
1766            }
1767        }
1768        stra[i] = sum / n_pairs;
1769    }
1770
1771    let mut centers: Vec<usize> = Vec::new();
1772    let mut score: f64 = stra[..div_win_size].iter().sum();
1773    let mut status = false;
1774    let mut start_i: usize = 0;
1775    let mut length: usize = 0;
1776
1777    // C loops `for( i=1; i<len-divWinSize; i++ )` and then flushes a still-open
1778    // region with `seg->end = i` (`mltaln9.c:11359`), where `i` is the loop's
1779    // EXIT value `len - divWinSize` — one past the last iterated column, not
1780    // the last iterated column itself. Using the last iterated `i` shifted
1781    // the trailing anchor left by one whenever `start + len` is even
1782    // (mtb_cds first 8 seqs, `--retree 2 --maxiterate 1000`: C 1412, we 1411).
1783    for i in 1..(len - div_win_size) {
1784        score = score - stra[i - 1] + stra[i + div_win_size - 1];
1785        if score > threshold {
1786            if !status {
1787                status = true;
1788                start_i = i;
1789                length = 0;
1790            }
1791            length += 1;
1792        }
1793        if score <= threshold || length > SEGMENTSIZE {
1794            if status {
1795                let end_i = i;
1796                let center = (start_i + end_i + div_win_size) / 2;
1797                centers.push(center);
1798                length = 0;
1799                status = false;
1800            }
1801        }
1802    }
1803    if status {
1804        let end_i = len - div_win_size;
1805        let center = (start_i + end_i + div_win_size) / 2;
1806        centers.push(center);
1807    }
1808
1809    let mut anchors: Vec<usize> = Vec::with_capacity(centers.len() + 2);
1810    anchors.push(0);
1811    anchors.extend(centers.into_iter().filter(|&c| c < len));
1812    anchors.push(len);
1813    anchors.dedup();
1814    anchors
1815}
1816
1817/// Iteratively refine a multiple alignment, mirroring C MAFFT's FFT-NS-i
1818/// behaviour: split the alignment at high-conservation anchors and run
1819/// `iterative_refine` on each column-slice independently, then re-concatenate.
1820///
1821/// Mirrors the segmented loop in `dvtditr.c:1085` driven by `searchAnchors`.
1822/// BESTFIRST refinement strategy — port of C MAFFT's
1823/// `parallelizationstrategy = BESTFIRST` (`tditeration.c:595-619`).
1824///
1825/// Where BAATARI2 (the default in both C and rust) walks each branch
1826/// sequentially in topology order and accepts improvements immediately,
1827/// BESTFIRST evaluates all branches against the **same baseline**
1828/// alignment per iteration, picks the one with the largest gain, applies
1829/// it, and repeats. The result is deterministic (verified C's BESTFIRST
1830/// gives byte-identical output across `--thread 1`, `--thread 4`, and
1831/// `--thread 8`) — multi-threading in C only parallelises the per-branch
1832/// evaluation, never reorders the global pick.
1833///
1834/// Terminates when no branch yields positive gain (converged) or
1835/// `max_iterations` is reached.
1836pub fn bestfirst_refine(
1837    alignment: &mut MultipleAlignment,
1838    topology: &Topology,
1839    scoring: &ScoringContext,
1840    params: &RefinementParams,
1841    constraints: Option<&LocalHomologyTable>,
1842) -> usize {
1843    let nseq = alignment.nseq();
1844    // `nseq == 2` is refined too — see `iterative_refine` (C `dvtditr.c:704-708`).
1845    if nseq < 2 || topology.steps.is_empty() {
1846        return 0;
1847    }
1848
1849    let branch_weights = BranchWeights::new(topology);
1850    let global_weights = mafft_tree::sequence_weights(topology);
1851    let use_global_weights = std::env::var("RUST_MAFFT_GLOBAL_WEIGHTS").is_ok();
1852    // Same C-mirroring zero-out as `iterative_refine` (dvtditr without -g).
1853    let mut gap = GapModel::new(scoring.gap.open as f64, 0.0)
1854        .with_legacy_gap_cost(params.legacy_gap_cost);
1855    if let Some(s) = params.shift {
1856        gap = gap.with_shift(s);
1857    }
1858
1859    let nsteps = topology.steps.len();
1860    let branch_map = build_branch_map(topology, nseq);
1861
1862    let mut iterations = 0usize;
1863    for _iter in 0..params.max_iterations {
1864        iterations += 1;
1865        // Snapshot baseline — every branch evaluates against this, NOT
1866        // against an updated mastercopy. That's the BESTFIRST signature
1867        // vs BAATARI2's eager-accept loop.
1868        let baseline_seqs = alignment.sequences.clone();
1869
1870        // For each branch: compute baseline old_score, run realign, compute
1871        // new_score, record (branch_id, gain, new_seqs) if gain > 0.
1872        let mut best: Option<(f64, Vec<Vec<u8>>)> = None;
1873        for step_idx in 0..nsteps {
1874            for (_side, group1, group2) in &branch_map[step_idx] {
1875                let weights = if use_global_weights {
1876                    global_weights.clone()
1877                } else {
1878                    branch_weights.weights_for_branch(topology, step_idx, *_side)
1879                };
1880                let w1: Vec<f64> = group1.iter().map(|&i| weights[i].max(params.minimum_weight)).collect();
1881                let w2: Vec<f64> = group2.iter().map(|&i| weights[i].max(params.minimum_weight)).collect();
1882                let s1w: f64 = w1.iter().sum();
1883                let s2w: f64 = w2.iter().sum();
1884                let w1n: Vec<f64> = if s1w > 0.0 { w1.iter().map(|w| w / s1w).collect() } else { vec![1.0; group1.len()] };
1885                let w2n: Vec<f64> = if s2w > 0.0 { w2.iter().map(|w| w / s2w).collect() } else { vec![1.0; group2.len()] };
1886
1887                let old_sub = compute_split_score(
1888                    group1, group2, &baseline_seqs, &weights, scoring,
1889                    params.minimum_weight,
1890                );
1891                let old_imp = if let Some(lh) = constraints {
1892                    compute_impmatch_diagonal(
1893                        group1, group2, &baseline_seqs, &w1n, &w2n, lh,
1894                    )
1895                } else { 0.0 };
1896                let old_score = old_sub + old_imp;
1897
1898                let mm_distarr: Option<Vec<f64>> = if params.unalign_level > 0.0 {
1899                    Some(branch_weights.dist_from_a_branch(topology, step_idx, *_side))
1900                } else {
1901                    None
1902                };
1903                let mm_input = mm_distarr.as_ref().map(|d| MultiMtxInput {
1904                    distarr: d,
1905                    unalign_level: params.unalign_level,
1906                });
1907
1908                if let Some((new_seqs, _, dp_impmatch)) = realign_all(
1909                    group1, group2, &baseline_seqs, &weights, scoring, &gap,
1910                    constraints, params.use_fft, mm_input.as_ref(),
1911                    params.minimum_weight,
1912                ) {
1913                    // C `tditeration.c:2185`: `identity = !strcmp(localcopy[s1], mastercopy[s1])`
1914                    // ANDed with the s2 comparison. When identical, `tscore = mscore`
1915                    // and gain = 0 — never accepted by `gain > 0` test. Skip the score
1916                    // recompute (matches C's branch and avoids FP drift around zero).
1917                    let s1 = group1[0];
1918                    let s2 = group2[0];
1919                    let changed = baseline_seqs[s1] != new_seqs[s1]
1920                        || baseline_seqs[s2] != new_seqs[s2];
1921                    if !changed {
1922                        continue;
1923                    }
1924                    let new_sub = compute_split_score(
1925                        group1, group2, &new_seqs, &weights, scoring,
1926                        params.minimum_weight,
1927                    );
1928                    let new_imp = if let Some(lh) = constraints {
1929                        dp_impmatch.unwrap_or_else(|| compute_impmatch_diagonal(
1930                            group1, group2, &new_seqs, &w1n, &w2n, lh,
1931                        ))
1932                    } else { 0.0 };
1933                    let new_score = new_sub + new_imp;
1934                    let gain = new_score - old_score;
1935                    if gain > 0.0 {
1936                        match &best {
1937                            None => best = Some((gain, new_seqs)),
1938                            Some((bg, _)) if gain > *bg => best = Some((gain, new_seqs)),
1939                            _ => {}
1940                        }
1941                    }
1942                }
1943            }
1944        }
1945
1946        match best {
1947            Some((_gain, new_seqs)) => {
1948                alignment.sequences = new_seqs;
1949            }
1950            None => break, // converged: no branch improves
1951        }
1952    }
1953    iterations
1954}
1955
1956/// `intergroup_score` clone that mirrors C's
1957/// `mltaln9.c::intergroup_score` (lines 404-477) FP-order EXACTLY:
1958/// the C code precomputes `efficient = eff1[i] * eff2[j]` THEN does
1959/// `*value += tmpscore * efficient` (one mul outside, then one fma).
1960/// This differs from `compute_split_score` which inlines as
1961/// `(tmpscore * wi) * wj + total` (a different product order).
1962///
1963/// The two formulations are mathematically equivalent but FP-different;
1964/// the difference is below the tie-break threshold for the
1965/// tree-dependent refinement (`iterative_refine` matches C byte-exactly
1966/// with `compute_split_score`), but for `dooneiteration`'s pure
1967/// leave-one-out splits the per-pair sub-ULP drift cumulates across
1968/// 36*2 iterations and flips two accept decisions on the 36-seq sample
1969/// (rust width 715 vs C 713 with `compute_split_score`).
1970///
1971/// Weights are normalised per-group exactly like
1972/// `fastconjuction_noname` (`tddis.c:548`) with `mineff = 0.0`.
1973fn intergroup_score_c_order(
1974    group1: &[usize],
1975    group2: &[usize],
1976    sequences: &[Vec<u8>],
1977    weights: &[f64],
1978    scoring: &ScoringContext,
1979) -> f64 {
1980    let w1: Vec<f64> = group1.iter().map(|&i| weights[i]).collect();
1981    let w2: Vec<f64> = group2.iter().map(|&i| weights[i]).collect();
1982    let s1: f64 = w1.iter().sum();
1983    let s2: f64 = w2.iter().sum();
1984    let w1n: Vec<f64> = if s1 > 0.0 { w1.iter().map(|w| w / s1).collect() } else { vec![1.0; group1.len()] };
1985    let w2n: Vec<f64> = if s2 > 0.0 { w2.iter().map(|w| w / s2).collect() } else { vec![1.0; group2.len()] };
1986
1987    let mut total = 0.0f64;
1988    for (i_local, &i) in group1.iter().enumerate() {
1989        let wi = w1n[i_local];
1990        for (j_local, &j) in group2.iter().enumerate() {
1991            let wj = w2n[j_local];
1992            // C `mltaln9.c:426`: `efficient = eff1[i] * eff2[j]`
1993            // (one rounding), then `mltaln9.c:466`:
1994            // `*value += (double)tmpscore * (double)efficient`
1995            // (one fma under arm64 clang, two roundings under baseline
1996            // x86-64 gcc — `fmadd` follows the `mafft_types::fp` policy).
1997            let efficient = wi * wj;
1998            let tmpscore = pairwise_score(&sequences[i], &sequences[j], scoring);
1999            total = fmadd(tmpscore, efficient, total);
2000        }
2001    }
2002    total
2003}
2004
2005/// `--oneiteration` "one-vs-others" refinement — port of
2006/// `disttbfast.c::dooneiteration` (lines 2217-2538). Runs AFTER
2007/// the progressive merge but BEFORE the regular tree-dependent
2008/// refinement (`iterative_refine` / `segmented_iterative_refine`).
2009///
2010/// Only triggered from the disttbfast-path modes (FFT-NS-2 and
2011/// FFT-NS-i); L/G/E-INS-i pipelines do not call this function in C
2012/// because they go through `pairlocalalign → tbfast → dvtditr`
2013/// and `scripts/mafft:2673` passes `-r` only to `disttbfast`.
2014///
2015/// ## Algorithm
2016///
2017/// `ITERATIVECYCLE = 2` (disttbfast.c:11) full passes over the
2018/// alignment. Each pass walks every sequence index `l in 0..nseq`
2019/// in order; for each `l` we treat the singleton `{l}` as group 1
2020/// and all other sequences as group 2, then attempt a fresh
2021/// realignment of that split. We compute the C
2022/// `intergroup_score` (substitution score between groups, no
2023/// constraints) before AND after the realign and KEEP the new
2024/// alignment iff the new score is at least as good as the
2025/// baseline (C's `if( nscore < oscore )` revert at
2026/// `disttbfast.c:2457`).
2027///
2028/// Constraints are NOT used (disttbfast path never sees
2029/// `constraint != 0`); `gap.extend = 0.0` matches the fact that
2030/// `dvtditr` is not invoked here — the gap-extension penalty
2031/// `--exp` is only baked into the progressive DP via disttbfast's
2032/// `-g $gexp`, not into this refinement step (`scripts/mafft`
2033/// only passes `-r ` for oneiteration, never `-g`).
2034///
2035/// `min_weight = 0.0` (matches C `fastconjuction_noname` call at
2036/// `disttbfast.c:2321-2322` with `mineff = 0.0`).
2037pub fn one_vs_others_refine(
2038    alignment: &mut MultipleAlignment,
2039    topology: &Topology,
2040    scoring: &ScoringContext,
2041    params: &RefinementParams,
2042) {
2043    let nseq = alignment.nseq();
2044    if nseq <= 2 {
2045        return;
2046    }
2047
2048    // C `disttbfast.c:11` `#define ITERATIVECYCLE 2`. Each cycle
2049    // walks every sequence index once.
2050    const ITERATIVE_CYCLE: usize = 2;
2051
2052    let weights = mafft_tree::sequence_weights(topology);
2053    // `gap.extend = 0.0`: disttbfast itself does pass `-g $gexp`
2054    // for progressive, but `dooneiteration` calls `Falign`/`A__align`
2055    // with the in-process `penalty_ex` global, which `scripts/mafft`
2056    // does not reset before invoking `disttbfast -r`. The progressive
2057    // step left it at the user's `-g` value, so we mirror by reading
2058    // `scoring.gap.extend` (NOT zeroing). Keep the legacy/shift
2059    // pieces from `params` so `--allowshift` interactions are
2060    // forwarded correctly.
2061    let mut gap = GapModel::new(scoring.gap.open as f64, scoring.gap.extend as f64)
2062        .with_legacy_gap_cost(params.legacy_gap_cost);
2063    if let Some(s) = params.shift {
2064        gap = gap.with_shift(s);
2065    }
2066
2067    let total_iters = nseq * ITERATIVE_CYCLE;
2068    for ll in 0..total_iters {
2069        let l = ll % nseq;
2070        // group1 = singleton {l}, group2 = the rest, preserving
2071        // sequence order (matches C's loop at disttbfast.c:2298-2300:
2072        // `for( i=0,j=0; i<njob; i++ ) if( i != l ) localmem[1][j++] = i;`).
2073        let group1 = vec![l];
2074        let group2: Vec<usize> = (0..nseq).filter(|&i| i != l).collect();
2075
2076        // Baseline `intergroup_score` BEFORE commongappick. C also
2077        // commongappicks the groups before the realign DP, but
2078        // intergroup_score skips gap-gap columns anyway so the
2079        // baseline value is invariant to that stripping. No
2080        // constraints (disttbfast path), so impmatch = 0.
2081        let oscore = intergroup_score_c_order(
2082            &group1, &group2, &alignment.sequences, &weights, scoring,
2083        );
2084
2085
2086        // Mirror C `dooneiteration` exactly: per-group commongappick
2087        // FIRST, THEN call progressive-Falign on the stripped data.
2088        // For singleton group1, commongappick strips every column
2089        // where seq[l] is a gap → result is the gap-free singleton.
2090        // For group2, strips columns where ALL N-1 seqs are gap.
2091        let width = alignment.sequences[0].len();
2092        let g1_gap_cols: Vec<bool> = (0..width)
2093            .map(|c| group1.iter().all(|&i| alignment.sequences[i][c] == b'-'))
2094            .collect();
2095        let g2_gap_cols: Vec<bool> = (0..width)
2096            .map(|c| group2.iter().all(|&i| alignment.sequences[i][c] == b'-'))
2097            .collect();
2098        // Build a transient "candidate" workspace where each group has
2099        // its common-gap columns removed. For sequences NOT in either
2100        // group we keep raw bytes (they're irrelevant to the merge).
2101        let mut candidate: Vec<Vec<u8>> = alignment.sequences.iter()
2102            .map(|s| s.clone()).collect();
2103        for &i in &group1 {
2104            let stripped: Vec<u8> = (0..width)
2105                .filter(|&c| !g1_gap_cols[c])
2106                .map(|c| alignment.sequences[i][c])
2107                .collect();
2108            candidate[i] = stripped;
2109        }
2110        for &i in &group2 {
2111            let stripped: Vec<u8> = (0..width)
2112                .filter(|&c| !g2_gap_cols[c])
2113                .map(|c| alignment.sequences[i][c])
2114                .collect();
2115            candidate[i] = stripped;
2116        }
2117        // Now merge the two stripped groups via the progressive
2118        // Falign-equivalent (kobetsubunkatsu=0). After this call,
2119        // candidate[i] for i ∈ group1 ∪ group2 holds the new
2120        // alignment row; other indices keep their pre-strip data
2121        // (and we never read them again before discarding).
2122        let _ = crate::progressive::merge_two_groups_progressive(
2123            &group1, &group2, &mut candidate, &weights, scoring, &gap,
2124            params.use_fft,
2125            // C `disttbfast` is invoked with `-O` ($termgapopt) for
2126            // FFT-NS-2/i, meaning `outgap = 0` (terminal gaps NOT
2127            // penalised). Matches the progressive merge call site
2128            // in `engine.rs` which passes `penalize_term_gaps=false`
2129            // for non-G-INS-i / non-parttree modes.
2130            false,
2131        );
2132
2133        let nscore = intergroup_score_c_order(
2134            &group1, &group2, &candidate, &weights, scoring,
2135        );
2136        // C `disttbfast.c:2457`: if( nscore < oscore ) revert.
2137        // Equivalent to accept-when-nscore-≥-oscore.
2138        if nscore >= oscore {
2139            alignment.sequences = candidate;
2140        }
2141    }
2142}
2143
2144/// Falls back to whole-alignment refinement when no anchors are found.
2145///
2146/// `constraints` is intentionally not sliced — C's segmented path uses
2147/// `kobetsubunkatsu=1` which goes single-segment whenever `constraint != 0`,
2148/// so this function is only called from non-constraint modes (FFT-NS-i).
2149pub fn segmented_iterative_refine(
2150    alignment: &mut MultipleAlignment,
2151    topology: &Topology,
2152    scoring: &ScoringContext,
2153    params: &RefinementParams,
2154    constraints: Option<&LocalHomologyTable>,
2155) -> usize {
2156    let nseq = alignment.nseq();
2157    // `nseq == 2` is refined too — see `iterative_refine` (C `dvtditr.c:704-708`).
2158    if nseq < 2 || topology.steps.is_empty() {
2159        return 0;
2160    }
2161
2162    let anchors = search_anchors_aa(
2163        &alignment.sequences,
2164        &scoring.substitution_matrix,
2165        &scoring.amino_map,
2166        20, 65,
2167    );
2168    if anchors.len() <= 2 {
2169        // No anchors found → behave like single-segment refinement.
2170        if refine_stats_enabled() {
2171            eprintln!("refine-segments: anchors={} segments=1 (unsegmented)", anchors.len());
2172        }
2173        return iterative_refine(alignment, topology, scoring, params, constraints);
2174    }
2175    if refine_stats_enabled() {
2176        eprintln!(
2177            "refine-segments: anchors={} segments={} len={}",
2178            anchors.len(),
2179            anchors.windows(2).filter(|w| w[0] < w[1]).count(),
2180            alignment.sequences.first().map_or(0, |s| s.len()),
2181        );
2182    }
2183
2184    let mut total_iters = 0usize;
2185    let mut concat: Vec<Vec<u8>> = vec![Vec::new(); nseq];
2186
2187    for w in anchors.windows(2) {
2188        let (start, end) = (w[0], w[1]);
2189        if start >= end { continue; }
2190
2191        let seg_seqs: Vec<Vec<u8>> = alignment.sequences.iter()
2192            .map(|s| s[start..end].to_vec())
2193            .collect();
2194        let mut seg_msa = MultipleAlignment {
2195            sequences: seg_seqs,
2196            names: alignment.names.clone(),
2197            score: 0.0,
2198            step_trace: Vec::new(),
2199            guide_tree: None,
2200            first_pass_sequences: None, distance_matrix: None,
2201        };
2202
2203        let iters = iterative_refine(&mut seg_msa, topology, scoring, params, constraints);
2204        total_iters += iters;
2205
2206        for (i, seq) in seg_msa.sequences.iter().enumerate() {
2207            concat[i].extend_from_slice(seq);
2208        }
2209    }
2210
2211    alignment.sequences = concat;
2212    total_iters
2213}
2214
2215#[cfg(test)]
2216mod tests {
2217    use super::*;
2218    use mafft_tree::{DistanceMatrix, upgma};
2219    use mafft_scoring::build_context;
2220    use mafft_types::{ScoringModel, SeqType};
2221    use crate::progressive::progressive_align;
2222
2223    /// Helper: build a 6-sequence UPGMA topology for branch-enumeration tests.
2224    fn make_6seq_topology() -> (Topology, usize) {
2225        let nseq = 6;
2226        let mut dm = DistanceMatrix::new(nseq);
2227        for i in 0..nseq {
2228            for j in (i + 1)..nseq {
2229                dm.set(i, j, (j - i) as f64 * 0.1);
2230            }
2231        }
2232        (upgma(&dm), nseq)
2233    }
2234
2235    /// Guard: `search_anchors_aa` closes a region that is still open when
2236    /// the sliding-window loop ends with `end = len - divWinSize` — the
2237    /// loop's exit value of `i` in C `searchAnchors` (`mltaln9.c`) — not
2238    /// with the last iterated column.
2239    ///
2240    /// Fully conserved 2-row alignment, every column scores 1000, so every
2241    /// window (20 * 1000) clears the 7800 threshold. Region 1 opens at
2242    /// `i = 1` and is closed by `length > SEGMENTSIZE` at `i = 151`
2243    /// (center `(1 + 151 + 20) / 2 = 86`); region 2 opens at 152 and is
2244    /// still open when the loop stops after `i = 179`. C flushes it with
2245    /// `end = 180` → center `(152 + 180 + 20) / 2 = 176`. Using `end = 179`
2246    /// gives 175, which is what shifted the last refinement segment of
2247    /// `mtb_cds_first8` one column left (C 1412 vs 1411).
2248    #[test]
2249    fn search_anchors_trailing_flush_uses_loop_exit_index() {
2250        let len = 200;
2251        let seqs = vec![vec![b'a'; len], vec![b'a'; len]];
2252        let matrix = vec![vec![1000i32]];
2253        let mut amino_map = [255u8; 256];
2254        amino_map[b'a' as usize] = 0;
2255        let anchors = search_anchors_aa(&seqs, &matrix, &amino_map, 20, 65);
2256        assert_eq!(anchors, vec![0, 86, 176, len]);
2257    }
2258
2259    // ---------------------------------------------------------------
2260    // Regression guards for the iterative-refinement fixes.
2261    // Each test targets one specific behavior ported from C's
2262    // TreeDependentIteration() in tditeration.c. If any of these
2263    // are accidentally reverted, at least one test will fail.
2264    // ---------------------------------------------------------------
2265
2266    /// Guard: branch count = (nseq-1)*2 - 1, matching C's nbranch formula.
2267    ///
2268    /// C computes `nbranch = (njob-1) * 2 - 1` (tditeration.c line 1458).
2269    /// The root step contributes only 1 branch (side 1), all others contribute
2270    /// 2 (sides 0 and 1). Reverting the root-step skip would produce
2271    /// (nseq-1)*2 branches instead.
2272    #[test]
2273    fn branch_count_matches_c_formula() {
2274        let (topo, nseq) = make_6seq_topology();
2275        let branch_map = build_branch_map(&topo, nseq);
2276        let total: usize = branch_map.iter().map(|sides| sides.len()).sum();
2277        let expected = (nseq - 1) * 2 - 1;
2278        assert_eq!(total, expected,
2279            "branch count should be (nseq-1)*2-1 = {expected}, got {total}");
2280    }
2281
2282    /// Guard: root step has exactly 1 branch (side 1 only).
2283    ///
2284    /// C forces `k = 1` at the root step (tditeration.c line 1667:
2285    /// `if( l == locnjob-2 ) k = 1`), skipping side 0 because at the
2286    /// root left-vs-complement and right-vs-complement are identical
2287    /// splits. Reverting would give the root step 2 branches.
2288    #[test]
2289    fn root_step_has_single_branch() {
2290        let (topo, nseq) = make_6seq_topology();
2291        let branch_map = build_branch_map(&topo, nseq);
2292        let root_branches = branch_map.last().unwrap();
2293        assert_eq!(root_branches.len(), 1,
2294            "root step should have 1 branch (side 1 only), got {}", root_branches.len());
2295        assert_eq!(root_branches[0].0, 1, "root branch should be side 1");
2296    }
2297
2298    /// Guard: non-root steps each have exactly 2 branches (sides 0 and 1).
2299    #[test]
2300    fn non_root_steps_have_two_branches() {
2301        let (topo, nseq) = make_6seq_topology();
2302        let branch_map = build_branch_map(&topo, nseq);
2303        for (step_idx, sides) in branch_map.iter().enumerate() {
2304            if step_idx < branch_map.len() - 1 {
2305                assert_eq!(sides.len(), 2,
2306                    "non-root step {step_idx} should have 2 branches, got {}", sides.len());
2307            }
2308        }
2309    }
2310
2311    /// Guard: default cut is 0.0 (accept only strict improvements).
2312    ///
2313    /// C's dvtditr.c sets `cut = 0.0` (line 71). The acceptance test is
2314    /// `tscore > mscore - cut/100*mscore`, so with cut=0 only strictly
2315    /// improving moves are accepted. Reverting to a nonzero cut would
2316    /// accept non-improving moves.
2317    #[test]
2318    fn default_cut_is_zero() {
2319        let params = RefinementParams::default();
2320        assert_eq!(params.cut, 0.0,
2321            "default cut must be 0.0 (strict improvement only), matching C's dvtditr.c");
2322    }
2323
2324    /// Guard: even iterations traverse steps forward, odd iterations reverse.
2325    ///
2326    /// C alternates direction (tditeration.c lines 1641-1648):
2327    ///   even → lin=0, ldf=+1 (forward)
2328    ///   odd  → lin=locnjob-2, ldf=-1 (reverse)
2329    /// This test verifies the first branch processed differs between
2330    /// iteration 0 (forward) and iteration 1 (reverse).
2331    #[test]
2332    fn alternating_direction_between_iterations() {
2333        let (topo, _) = make_6seq_topology();
2334        let nsteps = topo.steps.len();
2335
2336        let forward: Vec<usize> = (0..nsteps).collect();
2337        let reverse: Vec<usize> = (0..nsteps).rev().collect();
2338        assert_eq!(step_order(0, nsteps, false), forward, "even cycle walks forward");
2339        assert_eq!(step_order(1, nsteps, false), reverse, "odd cycle walks in reverse");
2340        assert_eq!(step_order(2, nsteps, false), forward);
2341        assert_eq!(step_order(3, nsteps, false), reverse);
2342        // They must differ (nsteps > 1 for any nseq > 2)
2343        assert_ne!(forward, reverse,
2344            "forward and reverse step orders must differ for alternation");
2345    }
2346
2347    /// Guard: C's `athread` (selected by `nthread > 0`) never reverses.
2348    ///
2349    /// The worker takes `branchtable[jobpos]` for `jobpos = 0 .. nbranch`
2350    /// (`tditeration.c:728-730`) and `branchtable` is only shuffled when
2351    /// `randomseed != 0` (`:522`; the script's default is 0), so every
2352    /// cycle walks the steps in ascending order. Reversing the odd cycles
2353    /// as the single-threaded loop does moved one gap column in one
2354    /// sequence of `mtb_cds_120x1400.fa` under `--thread 1`.
2355    #[test]
2356    fn athread_never_reverses_the_walk() {
2357        let (topo, _) = make_6seq_topology();
2358        let nsteps = topo.steps.len();
2359        let forward: Vec<usize> = (0..nsteps).collect();
2360        for iter in 0..5 {
2361            assert_eq!(step_order(iter, nsteps, true), forward,
2362                "athread cycle {iter} must walk forward");
2363        }
2364    }
2365
2366    /// Guard: oscillation detection terminates refinement early.
2367    ///
2368    /// C checks per-branch score history (tditeration.c lines 2343-2371)
2369    /// and stops if a branch's score at iteration N matches the score
2370    /// from iteration N-2. We verify that iterative_refine returns in
2371    /// fewer than max_iterations when running on inputs that converge
2372    /// quickly (which will produce identical scores across iterations).
2373    #[test]
2374    fn refinement_terminates_not_at_max_iterations() {
2375        let scoring = build_context(ScoringModel::Blosum(62), SeqType::Protein);
2376        // Three nearly-identical sequences: refinement should converge fast,
2377        // well before hitting 100 iterations.
2378        let seqs = vec![
2379            b"ACDEFGHIK".to_vec(),
2380            b"ACDEFGHIK".to_vec(),
2381            b"ACDEFGHIK".to_vec(),
2382        ];
2383        let names = vec!["s1".into(), "s2".into(), "s3".into()];
2384
2385        let mut dm = DistanceMatrix::new(3);
2386        dm.set(0, 1, 0.001);
2387        dm.set(0, 2, 0.001);
2388        dm.set(1, 2, 0.001);
2389        let topo = upgma(&dm);
2390
2391        let mut msa = progressive_align(&seqs, &names, &topo, &scoring, false, None);
2392        let params = RefinementParams {
2393            max_iterations: 100,
2394            ..Default::default()
2395        };
2396
2397        let iters = iterative_refine(&mut msa, &topo, &scoring, &params, None);
2398        // Identical sequences must converge immediately — either via the
2399        // identity check or via the convergence counter (nseq * 2 = 6).
2400        assert!(iters < 100,
2401            "expected early termination (convergence/oscillation), got {iters} iterations");
2402        assert!(iters <= 2,
2403            "identical sequences should converge in 1-2 iterations, got {iters}");
2404    }
2405
2406    // ---------------------------------------------------------------
2407    // Original functional tests (preserved).
2408    // ---------------------------------------------------------------
2409
2410    #[test]
2411    fn refinement_converges() {
2412        let scoring = build_context(ScoringModel::Blosum(62), SeqType::Protein);
2413        let seqs = vec![
2414            b"ACDEFGHIK".to_vec(),
2415            b"ACDEFHIK".to_vec(),
2416            b"ACDHIK".to_vec(),
2417        ];
2418        let names = vec!["s1".into(), "s2".into(), "s3".into()];
2419
2420        let mut dm = DistanceMatrix::new(3);
2421        dm.set(0, 1, 0.1);
2422        dm.set(0, 2, 0.3);
2423        dm.set(1, 2, 0.2);
2424        let topo = upgma(&dm);
2425
2426        let mut msa = progressive_align(&seqs, &names, &topo, &scoring, false, None);
2427        let params = RefinementParams {
2428            max_iterations: 10,
2429            ..Default::default()
2430        };
2431
2432        let iters = iterative_refine(&mut msa, &topo, &scoring, &params, None);
2433        assert!(iters <= 10);
2434
2435        let width = msa.width();
2436        for seq in &msa.sequences {
2437            assert_eq!(seq.len(), width);
2438        }
2439
2440        let ungapped: Vec<Vec<u8>> = msa.sequences.iter()
2441            .map(|s| s.iter().filter(|&&c| c != b'-').cloned().collect())
2442            .collect();
2443        assert_eq!(ungapped[0], b"ACDEFGHIK");
2444        assert_eq!(ungapped[1], b"ACDEFHIK");
2445        assert_eq!(ungapped[2], b"ACDHIK");
2446    }
2447
2448    #[test]
2449    fn refinement_preserves_width_consistency() {
2450        let scoring = build_context(ScoringModel::Blosum(62), SeqType::Protein);
2451        let seqs = vec![
2452            b"ACDEFGHIKLMNP".to_vec(),
2453            b"ACDEFHIKLMNP".to_vec(),
2454            b"ACDEHIKLMNP".to_vec(),
2455            b"ACDHIKLMNP".to_vec(),
2456        ];
2457        let names: Vec<String> = (0..4).map(|i| format!("s{i}")).collect();
2458
2459        let mut dm = DistanceMatrix::new(4);
2460        dm.set(0, 1, 0.1); dm.set(0, 2, 0.2); dm.set(0, 3, 0.3);
2461        dm.set(1, 2, 0.15); dm.set(1, 3, 0.25); dm.set(2, 3, 0.15);
2462        let topo = upgma(&dm);
2463
2464        let mut msa = progressive_align(&seqs, &names, &topo, &scoring, false, None);
2465        let params = RefinementParams { max_iterations: 5, ..Default::default() };
2466
2467        iterative_refine(&mut msa, &topo, &scoring, &params, None);
2468
2469        let width = msa.width();
2470        assert!(width > 0);
2471        for (i, seq) in msa.sequences.iter().enumerate() {
2472            assert_eq!(seq.len(), width, "sequence {i} has wrong width");
2473        }
2474
2475        // Verify residue preservation
2476        for (i, seq) in msa.sequences.iter().enumerate() {
2477            let residue_count = seq.iter().filter(|&&c| c != b'-').count();
2478            assert_eq!(residue_count, seqs[i].len(),
2479                "sequence {i} lost residues: {} vs {}", residue_count, seqs[i].len());
2480        }
2481    }
2482
2483    #[test]
2484    fn refinement_six_sequences() {
2485        let scoring = build_context(ScoringModel::Blosum(62), SeqType::Protein);
2486        let seqs = vec![
2487            b"ACDEFGHIKLMNPQR".to_vec(),
2488            b"ACDEFHIKLMNPQR".to_vec(),
2489            b"ACDEHIKLMNPQR".to_vec(),
2490            b"ACDHIKLMNPQR".to_vec(),
2491            b"ACDHIKLMNP".to_vec(),
2492            b"ACDHIKLM".to_vec(),
2493        ];
2494        let names: Vec<String> = (0..6).map(|i| format!("s{i}")).collect();
2495
2496        let mut dm = DistanceMatrix::new(6);
2497        for i in 0..6 {
2498            for j in (i + 1)..6 {
2499                dm.set(i, j, (j - i) as f64 * 0.1);
2500            }
2501        }
2502        let topo = upgma(&dm);
2503
2504        let mut msa = progressive_align(&seqs, &names, &topo, &scoring, false, None);
2505        let params = RefinementParams { max_iterations: 3, ..Default::default() };
2506
2507        iterative_refine(&mut msa, &topo, &scoring, &params, None);
2508
2509        let width = msa.width();
2510        for (i, seq) in msa.sequences.iter().enumerate() {
2511            assert_eq!(seq.len(), width, "sequence {i} has wrong width after refinement");
2512            let residues = seq.iter().filter(|&&c| c != b'-').count();
2513            assert_eq!(residues, seqs[i].len(),
2514                "sequence {i} lost residues during refinement");
2515        }
2516    }
2517
2518    /// Guard: FFT-accelerated refinement produces valid results and
2519    /// does not cause width explosion.
2520    ///
2521    /// C always uses Falign (FFT) in refinement. This test verifies that
2522    /// use_fft=true in RefinementParams produces a valid alignment with
2523    /// bounded width growth (no exponential blow-up).
2524    #[test]
2525    fn refinement_fft_no_width_explosion() {
2526        let scoring = build_context(ScoringModel::Blosum(62), SeqType::Protein);
2527        let seqs = vec![
2528            b"ACDEFGHIKLMNPQRSTVWY".to_vec(),
2529            b"ACDEFHIKLMNPQRSTVWY".to_vec(),
2530            b"ACDEHIKLMNPQRSTVWY".to_vec(),
2531            b"ACDHIKLMNPQRSTVWY".to_vec(),
2532            b"ACDHIKLMNPQR".to_vec(),
2533            b"ACDHIKLM".to_vec(),
2534        ];
2535        let names: Vec<String> = (0..6).map(|i| format!("s{i}")).collect();
2536
2537        let mut dm = DistanceMatrix::new(6);
2538        for i in 0..6 {
2539            for j in (i + 1)..6 {
2540                dm.set(i, j, (j - i) as f64 * 0.1);
2541            }
2542        }
2543        let topo = upgma(&dm);
2544
2545        let mut msa = progressive_align(&seqs, &names, &topo, &scoring, false, None);
2546        let pre_width = msa.width();
2547
2548        let params = RefinementParams {
2549            max_iterations: 5,
2550            use_fft: true,
2551            ..Default::default()
2552        };
2553
2554        iterative_refine(&mut msa, &topo, &scoring, &params, None);
2555
2556        let post_width = msa.width();
2557        // Width should not blow up — allow at most 2x growth for reasonable
2558        // refinement (C typically keeps width within ~10% of progressive).
2559        assert!(post_width <= pre_width * 2,
2560            "width explosion: {} -> {} (>2x growth)", pre_width, post_width);
2561
2562        for (i, seq) in msa.sequences.iter().enumerate() {
2563            assert_eq!(seq.len(), post_width, "sequence {i} has wrong width");
2564            let residues = seq.iter().filter(|&&c| c != b'-').count();
2565            assert_eq!(residues, seqs[i].len(),
2566                "sequence {i} lost residues during FFT refinement");
2567        }
2568    }
2569}