Skip to main content

mafft_core/
progressive.rs

1/// Progressive alignment following a guide tree.
2///
3/// Matches C's `treebase()` from `disttbfast.c`: at each merge step,
4/// only group1 and group2 sequences are modified. "Other" sequences
5/// are left untouched. Profiles are cached after each merge step
6/// (`cpmxhist`) to match C's exact float accumulation order.
7
8// §B.5 — `profile_cache` uses `BTreeMap` (not `HashMap`) for deterministic
9// iteration order. The cache is currently `.get()` / `.insert()` / `.remove()`
10// only, so HashMap's randomized order isn't an active hazard — but any future
11// refactor adding `.iter()` / `.values()` / `.keys()` would silently produce
12// non-deterministic alignment output. `BTreeMap` makes that future-safe at
13// negligible cost (cache size is bounded by `nseq`, keys are small
14// `Vec<usize>`).
15use mafft_types::fp::fmadd;
16use std::collections::BTreeMap;
17use mafft_align::{profile_align, pairwise_align11_ex, fft_profile_align, Profile, GapModel, AlignOp, FftAlignParams};
18use mafft_tree::{Topology, sequence_weights, compute_distfromtip};
19use mafft_types::ScoringContext;
20
21/// C `mltaln9.c::dist2offset`: offset = min(0, dist*0.5 - specificityconsideration).
22/// `dist` is `2 * distfromtip` so the result is `min(0, distfromtip - sc)`.
23pub(crate) fn dist2offset(dist: f64, sc: f64) -> f64 {
24    let v = dist * 0.5 - sc;
25    if v > 0.0 { 0.0 } else { v }
26}
27
28/// C `mltaln9.c::makedynamicmtx`. Adds `offset * 600` to every substitution
29/// score where `offset = dist2offset(2 * distfromtip, sc)`. Negative for
30/// shallow merges (close-related), zero for deep merges. Pulls divergent
31/// regions apart at shallow merges → wider final alignment.
32///
33/// C IMPORTANT: `mltaln9.c:15197-15203` SKIPS cells where amino[i] or
34/// amino[j] is '-' (gap_idx in our alphabet). We mirror that here — for
35/// protein gap_idx = 24, for DNA gap_idx = 24 (= `b'-'` mapped). Without
36/// this skip, profile DP cell scores diverge from C on the `--allowshift`
37/// per-step path even when input has no gap characters, because the static
38/// `amino_dynamicmtx` in C is char-indexed and the unshifted '-' row/col
39/// participates in the boundary handling.
40pub(crate) fn make_dynamic_matrix(base: &[Vec<f64>], distfromtip: f64, unalign_level: f64, gap_idx: usize) -> Vec<Vec<f64>> {
41    let offset = dist2offset(distfromtip * 2.0, unalign_level);
42    if offset == 0.0 {
43        return base.iter().map(|r| r.clone()).collect();
44    }
45    // C `mltaln9.c::makedynamicmtx` computes `out[i][j] = in[i][j] + offset * 600`
46    // per cell; arm64 clang fuses this into a single FMA, baseline x86-64
47    // gcc does not (`fmadd` follows the `mafft_types::fp` policy).
48    // Pre-computing `delta = offset * 600.0` then `v + delta` is two rounded ops
49    // and drifts ~1 ULP per cell. See [[project_allowshift_pairwise_fp]] for the
50    // BB12003 bisection. Same shape fix as `constraints.rs` `dyn_matrix` build.
51    base.iter()
52        .enumerate()
53        .map(|(i, row)| {
54            row.iter().enumerate()
55                .map(|(j, &v)| {
56                    if i == gap_idx || j == gap_idx { v } else { fmadd(offset, 600.0, v) }
57                })
58                .collect()
59        })
60        .collect()
61}
62
63#[derive(Debug, Clone)]
64pub struct MultipleAlignment {
65    pub sequences: Vec<Vec<u8>>,
66    pub names: Vec<String>,
67    pub score: f64,
68    /// Per-merge-step trace: one entry per progressive merge. Each entry is
69    /// `(clus1_size, clus2_size, width_after_merge, score)` matching C's
70    /// `RDBG step clus1 clus2 width score` debug line. Used for regression
71    /// tests that assert byte-level parity with C on a per-step basis;
72    /// harmless to ignore.
73    pub step_trace: Vec<StepTrace>,
74    /// Final progressive guide tree, kept around so callers can serialize
75    /// it for `--treeout` (`mltaln9.c::loadtree` and the various
76    /// `fixed_musclesupg_*_treeout` variants). Populated by the engine
77    /// when alignment completes; `None` for paths that don't track it
78    /// (e.g. tests building an `MultipleAlignment` directly).
79    pub guide_tree: Option<mafft_tree::Topology>,
80    /// Aligned MSA after the FIRST retree pass, BEFORE the final pass
81    /// rebuilds the guide tree and re-aligns. C MAFFT's `--parttree`
82    /// runs `splittbfast` twice and feeds CALL 1's output (`pre_1`) to
83    /// CALL 2 — both for `--reorder` and `--treeout`. We stash `pre_1`
84    /// here so the CLI can replay CALL 2's tree generation on the right
85    /// input.
86    pub first_pass_sequences: Option<Vec<Vec<u8>>>,
87    /// Pairwise distance matrix used by the engine for guide-tree
88    /// construction. Populated when the engine knows callers will need
89    /// it — currently `--distout` (writes `<input>.hat2`) and
90    /// `--scoreout` (computes the unweighted SP score). `None` for
91    /// paths that skip it (PartTree, --treein with a user-supplied
92    /// tree, tests building a MultipleAlignment directly).
93    pub distance_matrix: Option<mafft_tree::DistanceMatrix>,
94}
95
96#[derive(Debug, Clone, Copy)]
97pub struct StepTrace {
98    pub clus1: usize,
99    pub clus2: usize,
100    pub width: usize,
101    pub score: f64,
102}
103
104impl MultipleAlignment {
105    pub fn width(&self) -> usize {
106        self.sequences.first().map_or(0, |s| s.len())
107    }
108    pub fn nseq(&self) -> usize {
109        self.sequences.len()
110    }
111}
112
113/// Cached profile from a previous merge step.
114/// Stores the blended composition probability matrix, gap frequencies,
115/// and opening/closing gap counts — matching C's `cpmxhist`.
116#[derive(Debug, Clone)]
117struct CachedProfile {
118    profile: Profile,
119    /// Effective weight of this group (orieff), for blending at the next merge.
120    eff: f64,
121}
122
123/// Per-step branch tag for `--add` progressive alignment, mirroring
124/// C `disttbfast.c::mergeoralign[]` (lines 4188-4302):
125/// - `SkipExisting` ('n'): both subtrees are existing-only — the
126///   alignment is already in place; do nothing.
127/// - `NewLeft` ('1'): only the LEFT subtree contains a "new" sequence.
128/// - `NewRight` ('2'): only the RIGHT subtree contains a "new" sequence.
129/// - `Wide` ('w'): both subtrees contain new sequences — full DP merge.
130#[derive(Debug, Clone, Copy)]
131pub enum MergeOrAlign {
132    SkipExisting,
133    NewLeft,
134    NewRight,
135    Wide,
136}
137
138pub fn progressive_align(
139    sequences: &[Vec<u8>],
140    names: &[String],
141    topology: &Topology,
142    scoring: &ScoringContext,
143    use_fft: bool,
144    shift_penalty: Option<f64>,
145) -> MultipleAlignment {
146    progressive_align_with_constraints(
147        sequences, names, topology, scoring, use_fft, shift_penalty, None, false,
148    )
149}
150
151/// Run progressive alignment with all per-sequence weights set to 1.0.
152/// When normalized within each cluster, this yields uniform weights
153/// `1/clus_size` — mirroring C `splittbfast.c::fastconjuction_noweight`'s
154/// behavior (used by `--parttree` because `splittbfast.c:6` defines
155/// `WEIGHT 0`).
156///
157/// The standard `progressive_align` derives weights from the guide tree's
158/// branch lengths via `weighting::sequence_weights` (matching `disttbfast`
159/// / `tbfast`'s `fastconjuction_noname` path).
160pub fn progressive_align_unweighted(
161    sequences: &[Vec<u8>],
162    names: &[String],
163    topology: &Topology,
164    scoring: &ScoringContext,
165    use_fft: bool,
166    shift_penalty: Option<f64>,
167) -> MultipleAlignment {
168    let weights = vec![1.0f64; sequences.len()];
169    progressive_align_with_weights_override(
170        sequences, names, topology, scoring, use_fft, shift_penalty,
171        None, false, Some(&weights),
172    )
173}
174
175/// Progressive alignment for `--add`-style merges, with per-step
176/// `mergeoralign[]` tags driving skip/merge decisions. Mirrors C's
177/// `disttbfast.c::treebase` mergeoralign-aware loop:
178///   - `SkipExisting` branches: do NOTHING — both subtrees are
179///     existing-only and already aligned.
180///   - All other branches: standard merge_step_cached call, then
181///     propagate any new gap columns inserted during the merge to
182///     the OTHER existing rows (those not in left/right).
183///
184/// `sequences[0..n_existing]` should be the (commongappick-stripped)
185/// existing alignment, all the same width. `sequences[n_existing..]`
186/// are the new raw sequences. The topology is built over all
187/// `n_existing + n_new` sequences.
188///
189/// The gap-propagation step mirrors C's `insertnewgaps_bothorders`
190/// (`addfunctions.c:675`) at a coarser grain: instead of using
191/// `gaplen`/`gapmap` arrays from `findnewgaps`/`findcommongaps`, we
192/// observe the pre-vs-post-merge state of any active existing row to
193/// compute new-gap positions and apply the same insertions to the
194/// non-active existing rows. Sufficient for byte-identity when the
195/// per-step common-gap strip/restore would otherwise be a no-op
196/// (= no all-gap columns exist within any active existing subcluster).
197pub fn progressive_align_with_mergeoralign(
198    sequences: &[Vec<u8>],
199    names: &[String],
200    topology: &Topology,
201    mergeoralign: &[MergeOrAlign],
202    scoring: &ScoringContext,
203    use_fft: bool,
204) -> MultipleAlignment {
205    progressive_align_with_mergeoralign_n(
206        sequences, names, topology, mergeoralign, scoring, use_fft,
207        sequences.len(), // default: treat all rows as existing
208    )
209}
210
211/// Variant that takes `n_existing` explicitly so the caller can
212/// distinguish existing rows (whose intra-alignment must be preserved)
213/// from new rows (which can be freely re-aligned).
214pub fn progressive_align_with_mergeoralign_n(
215    sequences: &[Vec<u8>],
216    names: &[String],
217    topology: &Topology,
218    mergeoralign: &[MergeOrAlign],
219    scoring: &ScoringContext,
220    use_fft: bool,
221    n_existing: usize,
222) -> MultipleAlignment {
223    let nseq = sequences.len();
224    if nseq == 0 {
225        return MultipleAlignment {
226            sequences: Vec::new(), names: Vec::new(), score: 0.0, step_trace: Vec::new(), guide_tree: None, first_pass_sequences: None, distance_matrix: None,
227        };
228    }
229    if nseq == 1 {
230        return MultipleAlignment {
231            sequences: sequences.to_vec(), names: names.to_vec(), score: 0.0, step_trace: Vec::new(), guide_tree: None, first_pass_sequences: None, distance_matrix: None,
232        };
233    }
234
235    let weights = sequence_weights(topology);
236    let mut aligned: Vec<Vec<u8>> = sequences.to_vec();
237
238    let mut last_score = 0.0;
239    let gap = GapModel::new(scoring.gap.open as f64, scoring.gap.extend as f64);
240
241    let mut profile_cache: BTreeMap<Vec<usize>, CachedProfile> = BTreeMap::new();
242
243    // Track which rows are "already aligned" — i.e., have participated
244    // in some merge already. Existing rows start aligned (the input
245    // alignment). New rows become aligned after their first non-'n'
246    // merge. Mirrors C's `alreadyaligned[]` (`disttbfast.c:2257-2258`).
247    let mut already_aligned: Vec<bool> = (0..nseq).map(|i| i < n_existing).collect();
248
249    let mut step_trace: Vec<StepTrace> = Vec::with_capacity(topology.steps.len());
250    for (step_idx, step) in topology.steps.iter().enumerate() {
251        let tag = mergeoralign.get(step_idx).copied().unwrap_or(MergeOrAlign::Wide);
252        match tag {
253            MergeOrAlign::SkipExisting => {
254                let width = aligned[step.left[0]].len().max(aligned[step.right[0]].len());
255                step_trace.push(StepTrace {
256                    clus1: step.left.len(),
257                    clus2: step.right.len(),
258                    width,
259                    score: 0.0,
260                });
261            }
262            MergeOrAlign::NewRight | MergeOrAlign::NewLeft => {
263                // C `disttbfast.c:2745-2756`: for case '2' (NewRight), strip
264                // common gaps from group1 (the existing-only side) before the
265                // merge, then restore them afterwards. The DP runs between
266                // (stripped existing-side) and (full has-new-side). After the
267                // merge:
268                //   - existing-side and has-new-side are at post_merge_W.
269                //   - Restore: re-insert the stripped common-gap columns into
270                //     both sides as all-gap columns.
271                //   - For OTHER aligned rows (not in either side): expand
272                //     from L_pre to L_pre + N1 by inserting gap chars at the
273                //     positions corresponding to new merge gaps in the
274                //     existing-side representative.
275                //
276                // Case '1' (NewLeft) is "nai" (never reached) per
277                // `disttbfast.c:2934`, but we handle it symmetrically.
278                let (existing_grp, new_grp) = match tag {
279                    MergeOrAlign::NewRight => (&step.left[..], &step.right[..]),
280                    MergeOrAlign::NewLeft => (&step.right[..], &step.left[..]),
281                    _ => unreachable!(),
282                };
283
284                let pre_width = aligned[existing_grp[0]].len();
285
286                // findcommongaps: find columns where ALL existing_grp rows
287                // are gap. These are the columns to strip.
288                let pre_classification: Vec<bool> = (0..pre_width)
289                    .map(|col| {
290                        existing_grp.iter().all(|&i| {
291                            let c = aligned[i].get(col).copied().unwrap_or(b'-');
292                            c == b'-'
293                        })
294                    })
295                    .collect();
296                let n_gap_cols = pre_classification.iter().filter(|&&b| b).count();
297
298                // Save the pre-strip representative (for OTHER reconstruction).
299                let pre_rep_full = aligned[existing_grp[0]].clone();
300
301                // commongappick(existing_grp): strip the all-gap columns.
302                let pre_rep_stripped: Vec<u8>;
303                if n_gap_cols > 0 {
304                    pre_rep_stripped = pre_rep_full
305                        .iter()
306                        .enumerate()
307                        .filter(|(col, _)| !pre_classification[*col])
308                        .map(|(_, &c)| c)
309                        .collect();
310                    for &i in existing_grp {
311                        let stripped: Vec<u8> = aligned[i]
312                            .iter()
313                            .enumerate()
314                            .filter(|(col, _)| !pre_classification[*col])
315                            .map(|(_, &c)| c)
316                            .collect();
317                        aligned[i] = stripped;
318                    }
319                } else {
320                    pre_rep_stripped = pre_rep_full.clone();
321                }
322                let stripped_width = pre_rep_stripped.len();
323
324                // Run the merge. left/right ordering preserved.
325                last_score = merge_step_cached(
326                    &step.left,
327                    &step.right,
328                    &mut aligned,
329                    &weights,
330                    scoring,
331                    &gap,
332                    use_fft,
333                    &mut profile_cache,
334                    None,
335                    false,
336                    false,
337                    false, // c_compat off in --add path
338                    true,  // --add: pass 0 only, cache valid
339                );
340
341                let post_merge_width = aligned[existing_grp[0]].len();
342                let post_rep = aligned[existing_grp[0]].clone();
343
344                // Identify "new merge gap" positions in the post-merge
345                // existing-side representative. These are post-merge columns
346                // where the merge DP inserted a gap into existing_grp's rows
347                // (= positions not corresponding to any pre-strip char).
348                let new_merge_gap_set = compute_new_merge_gap_set(&pre_rep_stripped, &post_rep);
349
350                // Build mapping: stripped_idx -> pre-merge anchor positions,
351                // and gap_cols_before[s] = the gap_col positions sitting
352                // between the (s-1)-th and s-th anchor in pre-merge.
353                let anchor_positions: Vec<usize> = (0..pre_width)
354                    .filter(|&k| !pre_classification[k])
355                    .collect();
356                debug_assert_eq!(anchor_positions.len(), stripped_width);
357                let mut gap_cols_before: Vec<Vec<usize>> =
358                    vec![Vec::new(); stripped_width + 1];
359                {
360                    let mut s = 0usize;
361                    for k in 0..pre_width {
362                        if pre_classification[k] {
363                            gap_cols_before[s].push(k);
364                        } else {
365                            s += 1;
366                        }
367                    }
368                }
369
370                // restorecommongaps: for both groups, insert n_gap_cols all-gap
371                // columns at the right post-merge positions. For each
372                // gap_col at pre-merge position k_pre with stripped_idx_after = s,
373                // a gap col is inserted right BEFORE the s-th type A position
374                // in post-merge (after any preceding type B's).
375                if n_gap_cols > 0 {
376                    let inserts_per_strip_idx: Vec<usize> =
377                        gap_cols_before.iter().map(|v| v.len()).collect();
378                    let active: Vec<usize> =
379                        step.left.iter().chain(step.right.iter()).copied().collect();
380                    for &i in &active {
381                        aligned[i] = restore_common_gaps_to_merged_row(
382                            &aligned[i],
383                            &new_merge_gap_set,
384                            &inserts_per_strip_idx,
385                        );
386                    }
387                }
388
389                // R-6 closure: use `apply_c_insertnewgaps` (mirrors C
390                // addfunctions.c::insertnewgaps with profilealignment)
391                // to do the full multi-row reconstruction. Matches C
392                // byte-identically including the compression case where
393                // OTHER's residue absorbs an adjacent new-merge-gap col.
394                let n1 = post_merge_width - stripped_width;
395                if n1 > 0 || n_gap_cols > 0 {
396                    let active_set: std::collections::HashSet<usize> =
397                        step.left.iter().chain(step.right.iter()).copied().collect();
398                    let other_indices: Vec<usize> = (0..nseq)
399                        .filter(|i| already_aligned[*i] && !active_set.contains(i))
400                        .collect();
401
402                    // Use the C-style insertnewgaps port by default.
403                    // Closes R-6's `--add` adversarial divergence and
404                    // matches C byte-identically on canonical inputs.
405                    // RS_R6_PORT_OFF env var falls back to flat-padding
406                    // for diagnostics.
407                    let use_port = std::env::var("RS_R6_PORT_OFF").is_err();
408
409                    // Sanity: only run the port if all rows that participate
410                    // (active + OTHER) have consistent widths. Prior Wide
411                    // steps can leave OTHER rows at different widths than
412                    // the active-side post-restore width, which trips up
413                    // the lockstep walker. Falling back to flat-padding
414                    // for those cases keeps the canonical 30+6 fixture
415                    // byte-identical.
416                    let active_w = aligned[existing_grp[0]].len();
417                    let other_widths_consistent = other_indices.iter().all(|&i| {
418                        // OTHER should be at pre_width (before this step's
419                        // common-gap restoration). pre_width is the input
420                        // width of active before commongappick.
421                        aligned[i].len() == pre_width
422                    });
423
424                    if use_port && !other_indices.is_empty() && other_widths_consistent {
425                        // Rust uses '-' for new-merge-gaps (not '=' like C),
426                        // so findnewgaps on a string would return 0. Compute
427                        // gaplen + gapmap directly from new_merge_gap_set
428                        // and gap_cols_before.
429                        let group1_active = aligned[existing_grp[0]].clone();
430                        let post_restore_w = group1_active.len();
431                        let _ = active_w;
432                        let inserts_per_strip_idx: Vec<usize> =
433                            gap_cols_before.iter().map(|v| v.len()).collect();
434
435                        // gaplen[k] indexed by post-restore residue count
436                        // (= anchors + restored common-gaps). Value = new-
437                        // merge-gap count right after the k-th residue.
438                        let mut gaplen = vec![0usize; post_restore_w + 2];
439                        {
440                            let mut pos = 0usize;
441                            let mut s = 0usize;
442                            for q in 0..post_merge_width {
443                                if new_merge_gap_set.contains(&q) {
444                                    gaplen[pos] += 1;
445                                } else {
446                                    pos += inserts_per_strip_idx.get(s).copied().unwrap_or(0);
447                                    pos += 1;
448                                    s += 1;
449                                }
450                            }
451                        }
452
453                        // gapmap[k] indexed by post-restore position. Value =
454                        // common-gap block length starting at k.
455                        let mut gapmap = vec![0usize; post_restore_w + 2];
456                        {
457                            // Walk post-restore positions in the same order
458                            // restore_common_gaps_to_merged_row emits them.
459                            let mut p = 0usize;
460                            let mut s = 0usize;
461                            for q in 0..post_merge_width {
462                                if new_merge_gap_set.contains(&q) {
463                                    p += 1; // new-merge-gap col emitted as-is
464                                } else {
465                                    let n_common = inserts_per_strip_idx.get(s).copied().unwrap_or(0);
466                                    if n_common > 0 {
467                                        gapmap[p] = n_common;
468                                    }
469                                    p += n_common; // skip the restored '-' chars
470                                    p += 1; // the anchor itself
471                                    s += 1;
472                                }
473                            }
474                            // Trailing
475                            let n_common = inserts_per_strip_idx.get(anchor_positions.len()).copied().unwrap_or(0);
476                            if n_common > 0 && p < gapmap.len() {
477                                gapmap[p] = n_common;
478                            }
479                        }
480
481                        apply_c_insertnewgaps(
482                            &mut aligned,
483                            existing_grp,
484                            new_grp,
485                            &other_indices,
486                            &gaplen,
487                            &gapmap,
488                            scoring,
489                            &gap,
490                        );
491                    } else {
492                        for i in other_indices {
493                            let other_pre = aligned[i].clone();
494                            if other_pre.len() == pre_width {
495                                aligned[i] = build_other_post_restore_row(
496                                    &other_pre,
497                                    &anchor_positions,
498                                    &gap_cols_before,
499                                    &new_merge_gap_set,
500                                    post_merge_width,
501                                );
502                            }
503                        }
504                    }
505                }
506
507                // Mark new-side rows as aligned (existing-side was already).
508                for &i in new_grp {
509                    already_aligned[i] = true;
510                }
511
512                let width = aligned[step.left[0]].len();
513                let _ = (pre_width, n_gap_cols, stripped_width, post_merge_width, n1);
514                step_trace.push(StepTrace {
515                    clus1: step.left.len(),
516                    clus2: step.right.len(),
517                    width,
518                    score: last_score,
519                });
520            }
521            MergeOrAlign::Wide => {
522                // Both sides have new sequences. C does no per-step strip
523                // for case 'w' (`disttbfast.c:2745-2756` only strips for
524                // cases '1' and '2'). Just run the merge.
525                last_score = merge_step_cached(
526                    &step.left,
527                    &step.right,
528                    &mut aligned,
529                    &weights,
530                    scoring,
531                    &gap,
532                    use_fft,
533                    &mut profile_cache,
534                    None,
535                    false,
536                    false,
537                    false, // c_compat off in --add path
538                    true,  // --add: pass 0 only, cache valid
539                );
540
541                for &i in step.left.iter().chain(step.right.iter()) {
542                    already_aligned[i] = true;
543                }
544
545                let width = aligned[step.left[0]].len().max(aligned[step.right[0]].len());
546                step_trace.push(StepTrace {
547                    clus1: step.left.len(),
548                    clus2: step.right.len(),
549                    width,
550                    score: last_score,
551                });
552            }
553        }
554    }
555
556    let max_width = aligned.iter().map(|s| s.len()).max().unwrap_or(0);
557    for seq in &mut aligned {
558        seq.resize(max_width, b'-');
559    }
560
561    MultipleAlignment {
562        sequences: aligned, names: names.to_vec(), score: last_score, step_trace,
563        guide_tree: None, first_pass_sequences: None, distance_matrix: None,
564    }
565}
566
567/// Walk pre-strip and post-merge representatives to identify "new merge gap"
568/// post-merge positions. A new merge gap is a post-merge column that doesn't
569/// correspond to any pre-strip char (i.e., the DP's "insert" op put a gap in
570/// existing_grp at this column).
571///
572/// Lockstep: walk post; for each post char, if it equals the next pre char,
573/// advance both. Otherwise, mark it as a new merge gap. This works because
574/// the merge preserves residue order (only inserts gap chars), so pre and
575/// post agree on residues with `post_merge_W - L_strip` extra gaps inserted.
576fn compute_new_merge_gap_set(
577    pre_strip: &[u8],
578    post_merge: &[u8],
579) -> std::collections::HashSet<usize> {
580    let mut gap_set = std::collections::HashSet::new();
581    let mut p = 0usize;
582    for (q, &c) in post_merge.iter().enumerate() {
583        if p < pre_strip.len() && pre_strip[p] == c {
584            p += 1;
585        } else {
586            gap_set.insert(q);
587        }
588    }
589    gap_set
590}
591
592/// For a merged-group row at post_merge_W chars, expand to post-restore
593/// width by inserting `inserts_per_strip_idx[s]` gap chars right BEFORE
594/// the s-th type A (anchor) position, plus trailing
595/// `inserts_per_strip_idx[L_strip]` gap chars at the end. Mirrors C's
596/// `restorecommongaps` (`addfunctions.c:1453`).
597fn restore_common_gaps_to_merged_row(
598    row: &[u8],
599    new_merge_gap_set: &std::collections::HashSet<usize>,
600    inserts_per_strip_idx: &[usize],
601) -> Vec<u8> {
602    let total_inserts: usize = inserts_per_strip_idx.iter().sum();
603    let mut out = Vec::with_capacity(row.len() + total_inserts);
604    let mut s = 0usize;
605    for q in 0..row.len() {
606        if !new_merge_gap_set.contains(&q) {
607            for _ in 0..inserts_per_strip_idx[s] {
608                out.push(b'-');
609            }
610            out.push(row[q]);
611            s += 1;
612        } else {
613            out.push(row[q]);
614        }
615    }
616    let l_strip = inserts_per_strip_idx.len() - 1;
617    for _ in 0..inserts_per_strip_idx[l_strip] {
618        out.push(b'-');
619    }
620    out
621}
622
623/// commongappick — strip cols where ALL rows are '-' or '.'. Mirrors
624/// C `mltaln9.c::commongappick`. In-place.
625fn commongappick_inplace(mseq: &mut Vec<Vec<u8>>) {
626    if mseq.is_empty() || mseq[0].is_empty() { return; }
627    let n = mseq.len();
628    let len = mseq[0].len();
629    let mut keep = vec![true; len];
630    for j in 0..len {
631        let all_gap = (0..n).all(|i| {
632            let c = mseq[i].get(j).copied().unwrap_or(b'-');
633            c == b'-'
634        });
635        if all_gap { keep[j] = false; }
636    }
637    for row in mseq.iter_mut() {
638        let new_row: Vec<u8> = row.iter().enumerate()
639            .filter(|(j, _)| keep[*j])
640            .map(|(_, &c)| c)
641            .collect();
642        *row = new_row;
643    }
644}
645
646/// Port of C `addfunctions.c::profilealignment` (static at line 127).
647/// Aligns OTHER's content (`mseq0`) with group2's content (`mseq2`)
648/// at a single gap region, then marks group1's `mseq1` with '-' or
649/// '=' based on the resulting alignment. Mutates all three in place.
650///
651/// Used by `apply_c_insertnewgaps` at each new-merge-gap region
652/// adjacent to a common-gap restoration (gapshift2 > 0). The key
653/// effect: when commongappick strips all-gap cols from `mseq0` /
654/// `mseq2`, the resulting alignment is COMPRESSED (newlen < input
655/// width) — this is what closes R-6's `--add` adversarial gap.
656fn rs_profilealignment(
657    mseq0: &mut Vec<Vec<u8>>,
658    mseq1: &mut Vec<Vec<u8>>,
659    mseq2: &mut Vec<Vec<u8>>,
660    scoring: &ScoringContext,
661    gap: &GapModel,
662) {
663    // C: if (aln0[0][1] == 0 && aln2[0][1] == 0) return; — single-char
664    // case with --allowshift off. Skip — non-trivial case is the
665    // adversarial input.
666
667    commongappick_inplace(mseq0);
668    commongappick_inplace(mseq2);
669
670    // C edge case (line 154): if mseq2 first row is empty (no
671    // residues), fill all mseq2 with gap chars matching mseq0 length
672    // and return. mseq1 untouched.
673    let n0 = mseq0.len();
674    let n1 = mseq1.len();
675    let n2 = mseq2.len();
676    if n2 == 0 || mseq2[0].is_empty() {
677        let target_len = if n0 > 0 { mseq0[0].len() } else { 0 };
678        for row in mseq2.iter_mut() {
679            *row = vec![b'-'; target_len];
680        }
681        return;
682    }
683
684    // Build per-row weights as 1/alcount for non-all-gap rows, 0 else.
685    let alcount0 = mseq0.iter().filter(|r| r.iter().any(|&c| c != b'-')).count().max(1);
686    let alcount2 = mseq2.iter().filter(|r| r.iter().any(|&c| c != b'-')).count().max(1);
687    let eff0: Vec<f64> = mseq0.iter().map(|r| {
688        if r.iter().any(|&c| c != b'-') { 1.0 / alcount0 as f64 } else { 0.0 }
689    }).collect();
690    let eff2: Vec<f64> = mseq2.iter().map(|r| {
691        if r.iter().any(|&c| c != b'-') { 1.0 / alcount2 as f64 } else { 0.0 }
692    }).collect();
693
694    let mseq0_refs: Vec<&[u8]> = mseq0.iter().map(|v| v.as_slice()).collect();
695    let mseq2_refs: Vec<&[u8]> = mseq2.iter().map(|v| v.as_slice()).collect();
696    let prof0 = Profile::from_aligned(&mseq0_refs, &eff0, &scoring.amino_map, scoring.nalphabets);
697    let prof2 = Profile::from_aligned(&mseq2_refs, &eff2, &scoring.amino_map, scoring.nalphabets);
698
699    // C uses outgap=1 (headgp=1, tailgp=1) in the A__align call.
700    let aln = profile_align(&prof0, &prof2, &scoring.consweight_matrix, gap, true, true);
701
702    // Apply ops to produce new mseq0/mseq2.
703    let mut new_mseq0: Vec<Vec<u8>> = vec![Vec::new(); n0];
704    let mut new_mseq2: Vec<Vec<u8>> = vec![Vec::new(); n2];
705    let mut cur_i = vec![0usize; n0];
706    let mut cur_j = vec![0usize; n2];
707    for op in &aln.operations {
708        match op {
709            AlignOp::Match => {
710                for i in 0..n0 {
711                    new_mseq0[i].push(mseq0[i].get(cur_i[i]).copied().unwrap_or(b'-'));
712                    cur_i[i] += 1;
713                }
714                for j in 0..n2 {
715                    new_mseq2[j].push(mseq2[j].get(cur_j[j]).copied().unwrap_or(b'-'));
716                    cur_j[j] += 1;
717                }
718            }
719            AlignOp::Delete => {
720                for i in 0..n0 {
721                    new_mseq0[i].push(mseq0[i].get(cur_i[i]).copied().unwrap_or(b'-'));
722                    cur_i[i] += 1;
723                }
724                for j in 0..n2 {
725                    new_mseq2[j].push(b'-');
726                }
727            }
728            AlignOp::Insert => {
729                for i in 0..n0 {
730                    new_mseq0[i].push(b'-');
731                }
732                for j in 0..n2 {
733                    new_mseq2[j].push(mseq2[j].get(cur_j[j]).copied().unwrap_or(b'-'));
734                    cur_j[j] += 1;
735                }
736            }
737        }
738    }
739    *mseq0 = new_mseq0;
740    *mseq2 = new_mseq2;
741
742    // C lines 217-220: fill aln1 with '-' chars at newlen width.
743    let newlen = if n0 > 0 { mseq0[0].len() } else if n2 > 0 { mseq2[0].len() } else { 0 };
744    for row in mseq1.iter_mut() {
745        *row = vec![b'-'; newlen];
746    }
747
748    // C lines 222-242: at each j, if all aln0 are '-' AND all aln1 are
749    // '-' → mark all aln1 with '=' at j.
750    for j in 0..newlen {
751        let all_aln0_gap = mseq0.iter().all(|r| r.get(j).copied().unwrap_or(b'-') == b'-');
752        if !all_aln0_gap { continue; }
753        let all_aln1_gap = mseq1.iter().all(|r| r.get(j).copied().unwrap_or(b'-') == b'-');
754        if all_aln1_gap {
755            for row in mseq1.iter_mut() {
756                row[j] = b'=';
757            }
758        }
759    }
760    let _ = n1;
761}
762
763/// Port of C `addfunctions.c::findnewgaps` (line 327). gaplen[k]
764/// = number of '=' chars right after the k-th non-'=' char in seq.
765/// gaplen size = len(seq) + 1.
766pub fn findnewgaps(seq: &[u8]) -> Vec<usize> {
767    let mut gaplen = vec![0usize; seq.len() + 1];
768    let mut pos = 0;
769    for &c in seq {
770        if c == b'=' { gaplen[pos] += 1; }
771        else { pos += 1; }
772    }
773    gaplen
774}
775
776/// Port of C `addfunctions.c::insertnewgaps` (lines 445-650).
777/// Operates on the post-restore state (active rows already have
778/// common-gap chars restored; OTHER rows still at pre-merge width).
779/// Returns the new aligned state for all rows.
780///
781/// Key invariant: `aseq[OTHER]` walks pre-merge positions via index
782/// `j`; `aseq[active]` walks post-restore positions via `posin12`.
783/// `gaplen` is indexed by `j` (pre-merge); `gapmap` is indexed by
784/// `posin12` (post-restore).
785///
786/// **Currently SKIPS profilealignment** — for scenarios where the
787/// new-merge-gap is NOT adjacent to a common-gap (gapshift2==0
788/// branch always taken), this matches C exactly. Profilealignment
789/// will be added for the compression cases.
790pub fn apply_c_insertnewgaps(
791    aseq: &mut [Vec<u8>],
792    existing_grp: &[usize],
793    new_grp: &[usize],
794    other_indices: &[usize],
795    gaplen: &[usize],
796    gapmap: &[usize],
797    scoring: &ScoringContext,
798    gap: &GapModel,
799) {
800    if other_indices.is_empty() {
801        return; // C returns early when ngroup0 == 0
802    }
803
804    let rep = other_indices[0];
805    let len = aseq[rep].len();
806    let len0 = len + 1;
807
808    // Output buffers.
809    let mut out: Vec<Vec<u8>> = (0..aseq.len()).map(|_| Vec::with_capacity(len * 2 + 16)).collect();
810
811    let mut posin12 = 0usize;
812    let mut j = 0usize;
813    while j < len0 {
814        if j < gaplen.len() && gaplen[j] > 0 {
815            // Collect mseq0/1/2 for this gap region.
816            let gapshift = gaplen[j];
817            let mut mseq0: Vec<Vec<u8>> = (0..other_indices.len()).map(|_| Vec::new()).collect();
818            let mut mseq1: Vec<Vec<u8>> = (0..existing_grp.len()).map(|_| Vec::new()).collect();
819            let mut mseq2: Vec<Vec<u8>> = (0..new_grp.len()).map(|_| Vec::new()).collect();
820
821            // First gapshift = new-merge-gap region (gaplen[j] '=' chars in
822            // group1 post-restore).
823            for row in mseq0.iter_mut() {
824                for _ in 0..gapshift { row.push(b'-'); }
825            }
826            for (k, &i) in existing_grp.iter().enumerate() {
827                for kk in 0..gapshift {
828                    let c = aseq[i].get(posin12 + kk).copied().unwrap_or(b'-');
829                    mseq1[k].push(c);
830                }
831            }
832            for (k, &i) in new_grp.iter().enumerate() {
833                for kk in 0..gapshift {
834                    let c = aseq[i].get(posin12 + kk).copied().unwrap_or(b'-');
835                    mseq2[k].push(c);
836                }
837            }
838            posin12 += gapshift;
839
840            // Second gapshift = gapmap[posin12] (adjacent common-gap region).
841            // OTHER takes from pre-merge j..j+gapshift2; active take from
842            // posin12..posin12+gapshift2.
843            let gapshift2 = gapmap.get(posin12).copied().unwrap_or(0);
844            if gapshift2 > 0 {
845                for (k, &i) in other_indices.iter().enumerate() {
846                    for kk in 0..gapshift2 {
847                        let c = aseq[i].get(j + kk).copied().unwrap_or(b'-');
848                        mseq0[k].push(c);
849                    }
850                }
851                for (k, &i) in existing_grp.iter().enumerate() {
852                    for kk in 0..gapshift2 {
853                        let c = aseq[i].get(posin12 + kk).copied().unwrap_or(b'-');
854                        mseq1[k].push(c);
855                    }
856                }
857                for (k, &i) in new_grp.iter().enumerate() {
858                    for kk in 0..gapshift2 {
859                        let c = aseq[i].get(posin12 + kk).copied().unwrap_or(b'-');
860                        mseq2[k].push(c);
861                    }
862                }
863
864                // Run profilealignment — this can compress the mseq buffers.
865                rs_profilealignment(&mut mseq0, &mut mseq1, &mut mseq2, scoring, gap);
866
867                j += gapshift2;
868                posin12 += gapshift2;
869            }
870
871            // Append the (possibly compressed) mseq buffers to out.
872            for (k, &i) in other_indices.iter().enumerate() {
873                out[i].extend_from_slice(&mseq0[k]);
874            }
875            for (k, &i) in existing_grp.iter().enumerate() {
876                out[i].extend_from_slice(&mseq1[k]);
877            }
878            for (k, &i) in new_grp.iter().enumerate() {
879                out[i].extend_from_slice(&mseq2[k]);
880            }
881        }
882
883        // Block-copy: 1+ contiguous anchors where gaplen is 0.
884        let mut blocklen = 1;
885        let mut q = j + 1;
886        while q < len0 && q < gaplen.len() && gaplen[q] == 0 {
887            blocklen += 1;
888            q += 1;
889        }
890
891        // C's strncpy0 stops at source NUL. We mirror by breaking when
892        // the source index goes past the row's actual length.
893        for &i in other_indices {
894            for k in 0..blocklen {
895                if let Some(&c) = aseq[i].get(j + k) {
896                    if c != 0 { out[i].push(c); }
897                } else { break; }
898            }
899        }
900        for &i in existing_grp {
901            for k in 0..blocklen {
902                if let Some(&c) = aseq[i].get(posin12 + k) {
903                    if c != 0 { out[i].push(c); }
904                } else { break; }
905            }
906        }
907        for &i in new_grp {
908            for k in 0..blocklen {
909                if let Some(&c) = aseq[i].get(posin12 + k) {
910                    if c != 0 { out[i].push(c); }
911                } else { break; }
912            }
913        }
914
915        j += blocklen;
916        posin12 += blocklen;
917    }
918
919    // Trim trailing zeros from output rows (defensive).
920    for row in out.iter_mut() {
921        while row.last() == Some(&0) { row.pop(); }
922    }
923
924    // Copy back to aseq for affected rows.
925    for &i in other_indices.iter().chain(existing_grp).chain(new_grp) {
926        aseq[i] = std::mem::take(&mut out[i]);
927    }
928}
929
930/// `insertnewgaps` with `profilealignment` — partial port of C
931/// `addfunctions.c::insertnewgaps` (lines 445-650) including the
932/// per-gap-region `profilealignment` call (`addfunctions.c:127`).
933///
934/// **Status: NOT yet wired in.** This is the structural scaffold
935/// for closing R-6's `--add` adversarial-input divergence; the
936/// remaining piece is synchronizing the active rows' widths with
937/// OTHER's when profilealignment changes the gap-region width.
938///
939/// At each new-merge-gap region of length `g` in post-merge space,
940/// C extracts OTHER's content from the next `g` pre-merge anchor
941/// positions and runs a profile alignment against group2's content
942/// in the same region. The result reshuffles OTHER's residues into
943/// the gap region (instead of leaving them at the anchor positions
944/// and padding the gap region with '-' as
945/// `build_other_post_restore_row` does).
946///
947/// For default biological inputs this collapses to a no-op (the
948/// flat-padding result matches), but on adversarial inputs where
949/// the added sequence's insertions span positions where OTHER has
950/// residues, the profile alignment compresses the result by up to
951/// `g` columns.
952///
953/// Returns the new aligned slices for OTHER rows (in `other_indices`
954/// order). When integrated, the active rows (existing+new) must
955/// also be regenerated with the matching width.
956#[allow(dead_code, clippy::too_many_arguments)]
957fn insertnewgaps_with_profilealignment(
958    aligned: &[Vec<u8>],
959    other_indices: &[usize],
960    existing_grp: &[usize],
961    new_grp: &[usize],
962    anchor_positions: &[usize],
963    gap_cols_before: &[Vec<usize>],
964    new_merge_gap_set: &std::collections::HashSet<usize>,
965    post_merge_width: usize,
966    scoring: &ScoringContext,
967    gap: &GapModel,
968) -> Vec<Vec<u8>> {
969    // Build mapping: post-merge col q -> stripped_idx (anchor index in
970    // pre-merge) right AFTER it. Used to translate gap-region post-merge
971    // start positions into pre-merge anchor index ranges.
972    let stripped_width = anchor_positions.len();
973    let mut anchor_idx_at_post: Vec<usize> = Vec::with_capacity(post_merge_width);
974    {
975        let mut s = 0usize;
976        for q in 0..post_merge_width {
977            if !new_merge_gap_set.contains(&q) { s += 1; }
978            anchor_idx_at_post.push(s); // anchor count consumed up to and including q
979        }
980    }
981
982    // Find maximal runs of consecutive new_merge_gap_set columns.
983    let mut gap_runs: Vec<(usize, usize)> = Vec::new(); // (q_start, length)
984    {
985        let mut q = 0;
986        while q < post_merge_width {
987            if new_merge_gap_set.contains(&q) {
988                let start = q;
989                while q < post_merge_width && new_merge_gap_set.contains(&q) { q += 1; }
990                gap_runs.push((start, q - start));
991            } else {
992                q += 1;
993            }
994        }
995    }
996
997    // Build OTHER's pre-merge content (one row per other index).
998    let other_pre: Vec<Vec<u8>> = other_indices.iter().map(|&i| aligned[i].clone()).collect();
999    // post-restore output rows for OTHER (to be filled).
1000    let mut other_out: Vec<Vec<u8>> = vec![Vec::with_capacity(post_merge_width); other_indices.len()];
1001
1002    // Walk anchors s = 0..stripped_width and maintain post-merge col q.
1003    let mut s = 0usize;
1004    let mut run_idx = 0usize;
1005    let mut q = 0usize;
1006    while s < stripped_width {
1007        // Skip any new-merge-gap run starting at q.
1008        if run_idx < gap_runs.len() && gap_runs[run_idx].0 == q {
1009            let (g_start, g_len) = gap_runs[run_idx];
1010            run_idx += 1;
1011            // Consume the next g_len anchors as OTHER's source for this
1012            // gap region (C `insertnewgaps:571`: mseq0 = seq[list0[i]]+j
1013            // for gapshift chars).
1014            let consume = g_len.min(stripped_width - s);
1015            let src_anchors = &anchor_positions[s..s + consume];
1016
1017            // mseq0: OTHER's chars at those anchor positions.
1018            let mseq0: Vec<Vec<u8>> = other_pre.iter().map(|row| {
1019                src_anchors.iter().map(|&k| row.get(k).copied().unwrap_or(b'-')).collect()
1020            }).collect();
1021
1022            // mseq2: new-side (group2) post-merge chars in the gap run.
1023            // group2 was updated by merge_step_cached; aligned[new_grp[k]]
1024            // currently has post-merge content (already updated by
1025            // restore_common_gaps_to_merged_row in the caller). But here
1026            // we receive raw aligned BEFORE restore in the new path, so
1027            // we use the post-merge index directly.
1028            let mseq2: Vec<Vec<u8>> = new_grp.iter().map(|&i| {
1029                aligned[i].iter().skip(g_start).take(g_len).copied().collect()
1030            }).collect();
1031
1032            // mseq0 commongappick: for our case (singletons or small)
1033            // the all-gap check is a no-op since rows here came from
1034            // anchor positions where seq[0] had a residue (so OTHER
1035            // could be residue or gap but not all-gap).
1036            // Build profiles and run profile_align.
1037            let m_refs: Vec<&[u8]> = mseq0.iter().map(|v| v.as_slice()).collect();
1038            let n_refs: Vec<&[u8]> = mseq2.iter().map(|v| v.as_slice()).collect();
1039            let n0 = m_refs.len();
1040            let _n2 = n_refs.len();
1041            let alcount0 = mseq0.iter().filter(|r| r.iter().any(|&c| c != b'-')).count().max(1);
1042            let alcount2 = mseq2.iter().filter(|r| r.iter().any(|&c| c != b'-')).count().max(1);
1043            let w0: Vec<f64> = mseq0.iter().map(|r| {
1044                if r.iter().any(|&c| c != b'-') { 1.0 / alcount0 as f64 } else { 0.0 }
1045            }).collect();
1046            let w2: Vec<f64> = mseq2.iter().map(|r| {
1047                if r.iter().any(|&c| c != b'-') { 1.0 / alcount2 as f64 } else { 0.0 }
1048            }).collect();
1049            let prof0 = Profile::from_aligned(&m_refs, &w0, &scoring.amino_map, scoring.nalphabets);
1050            let prof2 = Profile::from_aligned(&n_refs, &w2, &scoring.amino_map, scoring.nalphabets);
1051
1052            // C uses outgap=1 in the profilealignment A__align call.
1053            let aln = profile_align(&prof0, &prof2, &scoring.consweight_matrix, gap, true, true);
1054
1055            // Apply ops to each OTHER row: emit the aligned mseq0[i][k]
1056            // characters at each Match/Delete op, and '-' at Insert ops.
1057            let mut cur_i = vec![0usize; n0];
1058            for op in &aln.operations {
1059                for i in 0..n0 {
1060                    match op {
1061                        AlignOp::Match | AlignOp::Delete => {
1062                            let c = mseq0[i].get(cur_i[i]).copied().unwrap_or(b'-');
1063                            other_out[i].push(c);
1064                            cur_i[i] += 1;
1065                        }
1066                        AlignOp::Insert => {
1067                            other_out[i].push(b'-');
1068                        }
1069                    }
1070                }
1071            }
1072            s += consume;
1073            q = g_start + g_len;
1074            continue;
1075        }
1076
1077        // Not in a gap run: emit OTHER's chars at common-gap positions
1078        // before this anchor, then OTHER's char at the anchor.
1079        if s < stripped_width {
1080            for &k_pre in &gap_cols_before[s] {
1081                for (i, row) in other_pre.iter().enumerate() {
1082                    other_out[i].push(row.get(k_pre).copied().unwrap_or(b'-'));
1083                }
1084            }
1085            for (i, row) in other_pre.iter().enumerate() {
1086                other_out[i].push(row.get(anchor_positions[s]).copied().unwrap_or(b'-'));
1087            }
1088            s += 1;
1089            q += 1;
1090        }
1091    }
1092
1093    // Tail common-gap positions (after the last anchor).
1094    if let Some(tail) = gap_cols_before.get(stripped_width) {
1095        for &k_pre in tail {
1096            for (i, row) in other_pre.iter().enumerate() {
1097                other_out[i].push(row.get(k_pre).copied().unwrap_or(b'-'));
1098            }
1099        }
1100    }
1101
1102    // Trailing gap runs (after all anchors consumed).
1103    while run_idx < gap_runs.len() {
1104        let (_, g_len) = gap_runs[run_idx];
1105        for _ in 0..g_len {
1106            for row in other_out.iter_mut() {
1107                row.push(b'-');
1108            }
1109        }
1110        run_idx += 1;
1111    }
1112
1113    let _ = (existing_grp, anchor_idx_at_post); // referenced for future debug
1114    other_out
1115}
1116
1117/// For an OTHER (already-aligned, not-in-merge) row at pre-merge L_pre,
1118/// build its post-restore representation. OTHER preserves its char at
1119/// type A (anchor) and type C (gap_col) positions, and gets gap chars at
1120/// type B (new merge gap) positions. Mirrors C's `insertnewgaps`
1121/// (`addfunctions.c:445`) but without the profilealignment refinement.
1122fn build_other_post_restore_row(
1123    other_pre: &[u8],
1124    anchor_positions: &[usize],
1125    gap_cols_before: &[Vec<usize>],
1126    new_merge_gap_set: &std::collections::HashSet<usize>,
1127    post_merge_w: usize,
1128) -> Vec<u8> {
1129    let total_size = other_pre.len() + new_merge_gap_set.len();
1130    let mut out: Vec<u8> = Vec::with_capacity(total_size);
1131    let mut s = 0usize;
1132    for q in 0..post_merge_w {
1133        if new_merge_gap_set.contains(&q) {
1134            out.push(b'-');
1135        } else {
1136            for &k_pre in &gap_cols_before[s] {
1137                out.push(other_pre.get(k_pre).copied().unwrap_or(b'-'));
1138            }
1139            out.push(other_pre.get(anchor_positions[s]).copied().unwrap_or(b'-'));
1140            s += 1;
1141        }
1142    }
1143    let l_strip = anchor_positions.len();
1144    for &k_pre in &gap_cols_before[l_strip] {
1145        out.push(other_pre.get(k_pre).copied().unwrap_or(b'-'));
1146    }
1147    out
1148}
1149
1150/// Run progressive alignment merges 0..n_steps and return the
1151/// intermediate `aligned[]` state at that point.
1152///
1153/// Used by FFI cross-validation tests that need to reproduce a
1154/// specific step's input profiles without driving the binary or
1155/// using env-var-controlled file dumps. Each sequence's length in
1156/// the returned `Vec<Vec<u8>>` matches whatever cluster width it has
1157/// at step `n_steps` entry (sequences in different clusters have
1158/// different widths, mirroring C's progressive merge state).
1159///
1160/// Passing `n_steps == topology.steps.len()` runs all merges and
1161/// returns the final padded alignment.
1162pub fn progressive_align_partial(
1163    sequences: &[Vec<u8>],
1164    topology: &Topology,
1165    scoring: &ScoringContext,
1166    use_fft: bool,
1167    shift_penalty: Option<f64>,
1168    n_steps: usize,
1169) -> Vec<Vec<u8>> {
1170    let nseq = sequences.len();
1171    if nseq <= 1 {
1172        return sequences.to_vec();
1173    }
1174
1175    let weights = sequence_weights(topology);
1176    let mut aligned: Vec<Vec<u8>> = sequences.to_vec();
1177
1178    let mut gap = GapModel::new(scoring.gap.open as f64, scoring.gap.extend as f64);
1179    if let Some(shift) = shift_penalty {
1180        gap = gap.with_shift(shift);
1181    }
1182
1183    let mut profile_cache: BTreeMap<Vec<usize>, CachedProfile> = BTreeMap::new();
1184
1185    let limit = n_steps.min(topology.steps.len());
1186    for step in topology.steps.iter().take(limit) {
1187        merge_step_cached(
1188            &step.left, &step.right, &mut aligned, &weights, scoring, &gap, use_fft,
1189            &mut profile_cache, None, false, false,
1190            false, // c_compat off in partial replay (test diagnostics)
1191            true,  // partial replay mirrors pass 0
1192        );
1193    }
1194    aligned
1195}
1196
1197/// Progressive alignment with optional local-homology constraints.
1198///
1199/// When `constraints` is `Some`, every merge calls `profile_align_imp`
1200/// with a per-merge impmtx built via `mafft-align::build_imp_matrix` —
1201/// mirroring what `tbfast` does in C's L-INS-i pipeline (`Falign_localhom`,
1202/// or the non-FFT `partA__align` per segment). Without this the initial
1203/// progressive alignment is FFT-NS-i-like and only refinement sees the
1204/// constraints, leaving L-INS-i/E-INS-i shapes systematically off vs C.
1205pub fn progressive_align_with_constraints(
1206    sequences: &[Vec<u8>],
1207    names: &[String],
1208    topology: &Topology,
1209    scoring: &ScoringContext,
1210    use_fft: bool,
1211    shift_penalty: Option<f64>,
1212    constraints: Option<&mafft_types::LocalHomologyTable>,
1213    penalize_term_gaps: bool,
1214) -> MultipleAlignment {
1215    progressive_align_with_weights_override(
1216        sequences, names, topology, scoring, use_fft, shift_penalty,
1217        constraints, penalize_term_gaps, None,
1218    )
1219}
1220
1221/// Like `progressive_align_with_constraints` but allows overriding the
1222/// per-sequence weights. When `weights_override` is `Some(w)`, each
1223/// `w[i]` is used directly (still normalized within each cluster at
1224/// merge time). When `None`, the weights come from
1225/// `sequence_weights(topology)` (the tree-derived
1226/// `weightFromABranch`-based defaults).
1227pub fn progressive_align_with_weights_override(
1228    sequences: &[Vec<u8>],
1229    names: &[String],
1230    topology: &Topology,
1231    scoring: &ScoringContext,
1232    use_fft: bool,
1233    shift_penalty: Option<f64>,
1234    constraints: Option<&mafft_types::LocalHomologyTable>,
1235    penalize_term_gaps: bool,
1236    weights_override: Option<&[f64]>,
1237) -> MultipleAlignment {
1238    progressive_align_full(
1239        sequences, names, topology, scoring, use_fft, shift_penalty,
1240        constraints, penalize_term_gaps, weights_override, 0.0, false, false,
1241    )
1242}
1243
1244/// Like `progressive_align_with_weights_override` but with `unalign_level`
1245/// (C's `specificityconsideration`). When `unalign_level > 0`, builds a
1246/// per-step dynamic substitution matrix that scales by `(distfromtip -
1247/// unalign_level) * 600` (clamped at 0). Mirrors `disttbfast.c:2304-2307` +
1248/// `mltaln9.c::makedynamicmtx`. `--allowshift` sets this to 0.8.
1249pub fn progressive_align_full(
1250    sequences: &[Vec<u8>],
1251    names: &[String],
1252    topology: &Topology,
1253    scoring: &ScoringContext,
1254    use_fft: bool,
1255    shift_penalty: Option<f64>,
1256    constraints: Option<&mafft_types::LocalHomologyTable>,
1257    penalize_term_gaps: bool,
1258    weights_override: Option<&[f64]>,
1259    unalign_level: f64,
1260    legacy_gap_cost: bool,
1261    memsave_dp: bool,
1262) -> MultipleAlignment {
1263    progressive_align_full_c_compat(
1264        sequences, names, topology, scoring, use_fft, shift_penalty,
1265        constraints, penalize_term_gaps, weights_override, unalign_level,
1266        legacy_gap_cost, memsave_dp, false,
1267    )
1268}
1269
1270/// Like `progressive_align_full` but with an explicit `c_compat` flag
1271/// that enables C MAFFT's static-TLS cpmx memoization (see
1272/// `mafft_align::profile::CPMX_MEMO`). Default callers should use
1273/// `progressive_align_full` which passes `c_compat=false`.
1274pub fn progressive_align_full_c_compat(
1275    sequences: &[Vec<u8>],
1276    names: &[String],
1277    topology: &Topology,
1278    scoring: &ScoringContext,
1279    use_fft: bool,
1280    shift_penalty: Option<f64>,
1281    constraints: Option<&mafft_types::LocalHomologyTable>,
1282    penalize_term_gaps: bool,
1283    weights_override: Option<&[f64]>,
1284    unalign_level: f64,
1285    legacy_gap_cost: bool,
1286    memsave_dp: bool,
1287    c_compat: bool,
1288) -> MultipleAlignment {
1289    progressive_align_full_c_compat_ex(
1290        sequences, names, topology, scoring, use_fft, shift_penalty,
1291        constraints, penalize_term_gaps, weights_override, unalign_level,
1292        legacy_gap_cost, memsave_dp, c_compat, true,
1293    )
1294}
1295
1296/// Like `progressive_align_full_c_compat` but with an explicit `use_cache`
1297/// flag. When `use_cache` is false, every merge rebuilds its child profiles
1298/// fresh from the current `aligned[]` state via `cpmx_calc_new`, mirroring
1299/// C MAFFT's `dooneiteration` behavior (`disttbfast.c:2288-2289` —
1300/// `cpmxchild0/1 = NULL`). When true (default), child profiles are blended
1301/// from cached parent profiles via `blend_profiles_exact`, mirroring C's
1302/// `createcpmxresult` in `treebase` (pass 0). The blend matches C's
1303/// `createcpmxresult` exactly (including its "tsukawanai" comment that
1304/// excludes the eff*1.0 gap contribution at gap-insertion positions —
1305/// see `Salignmm.c:622-626`); using it in pass-1+ would diverge from C,
1306/// which forces fresh `cpmx_calc_new` there.
1307pub fn progressive_align_full_c_compat_ex(
1308    sequences: &[Vec<u8>],
1309    names: &[String],
1310    topology: &Topology,
1311    scoring: &ScoringContext,
1312    use_fft: bool,
1313    shift_penalty: Option<f64>,
1314    constraints: Option<&mafft_types::LocalHomologyTable>,
1315    penalize_term_gaps: bool,
1316    weights_override: Option<&[f64]>,
1317    unalign_level: f64,
1318    legacy_gap_cost: bool,
1319    memsave_dp: bool,
1320    c_compat: bool,
1321    use_cache: bool,
1322) -> MultipleAlignment {
1323    let nseq = sequences.len();
1324    if nseq == 0 {
1325        return MultipleAlignment {
1326            sequences: Vec::new(), names: Vec::new(), score: 0.0, step_trace: Vec::new(), guide_tree: None, first_pass_sequences: None, distance_matrix: None,
1327        };
1328    }
1329    if nseq == 1 {
1330        return MultipleAlignment {
1331            sequences: sequences.to_vec(), names: names.to_vec(), score: 0.0, step_trace: Vec::new(), guide_tree: None, first_pass_sequences: None, distance_matrix: None,
1332        };
1333    }
1334
1335    let weights = match weights_override {
1336        Some(w) => w.to_vec(),
1337        None => sequence_weights(topology),
1338    };
1339    let mut aligned: Vec<Vec<u8>> = sequences.to_vec();
1340
1341    let mut last_score = 0.0;
1342    // C MAFFT only passes `-g $gexp` (the `--exp` extension penalty)
1343    // to `disttbfast` (the FFT-NS-2 / FFT-NS-i progressive binary).
1344    // The constrained progressive path goes through `tbfast` instead,
1345    // which does NOT get `-g` (`scripts/mafft:2525-2550`). So when
1346    // constraints are present (L/G/E-INS-i path), we must zero out
1347    // the extend penalty regardless of what the user set `--exp`
1348    // to — matching C's effective behaviour. Refinement enforces
1349    // the same constraint in `refinement.rs::iterative_refine`.
1350    let progressive_extend = if constraints.is_some() {
1351        0.0
1352    } else {
1353        scoring.gap.extend as f64
1354    };
1355    let mut gap = GapModel::new(scoring.gap.open as f64, progressive_extend)
1356        .with_legacy_gap_cost(legacy_gap_cost);
1357    if let Some(shift) = shift_penalty {
1358        gap = gap.with_shift(shift);
1359    }
1360
1361    // Profile cache: maps a set of sequence indices (sorted) to its cached profile.
1362    // After each merge, the merged profile is stored so the next merge can reuse it.
1363    let mut profile_cache: BTreeMap<Vec<usize>, CachedProfile> = BTreeMap::new();
1364
1365    // `--c-compat`: reset per-thread cpmx memo at start of this pass
1366    // (mirrors C `Salignmm.c:1365-1366` which sets previousfirstlen=-1,
1367    // previousicyc=-1 on buffer resize). For multi-pass progressive
1368    // (retree>1), each pass starts with a clean memo so cross-pass
1369    // state doesn't leak.
1370    if c_compat {
1371        mafft_align::reset_cpmx_memo();
1372    }
1373
1374    // Per-step dynamic-matrix offset. `--allowshift`/`--unalignlevel`
1375    // triggers `unalign_level > 0`. C builds a fresh `dynamicmtx` per
1376    // step from the tree node height (`disttbfast.c:2304-2307`).
1377    let distfromtip: Vec<f64> = if unalign_level > 0.0 {
1378        compute_distfromtip(topology)
1379    } else {
1380        Vec::new()
1381    };
1382    // Per-step scoring contexts (only when unalign_level > 0). Each
1383    // entry differs from `scoring` only in `consweight_matrix` (the
1384    // f64 version of the substitution matrix used by all DP routines).
1385    let gap_idx = scoring.amino_map[b'-' as usize] as usize;
1386    let dyn_scoring: Vec<ScoringContext> = if unalign_level > 0.0 {
1387        distfromtip
1388            .iter()
1389            .map(|&dft| {
1390                let mut s = scoring.clone();
1391                s.consweight_matrix =
1392                    make_dynamic_matrix(&scoring.consweight_matrix, dft, unalign_level, gap_idx);
1393                s
1394            })
1395            .collect()
1396    } else {
1397        Vec::new()
1398    };
1399
1400    let mut step_trace: Vec<StepTrace> = Vec::with_capacity(topology.steps.len());
1401    for (step_idx, step) in topology.steps.iter().enumerate() {
1402        let step_scoring: &ScoringContext = if unalign_level > 0.0 {
1403            &dyn_scoring[step_idx]
1404        } else {
1405            scoring
1406        };
1407        last_score = merge_step_cached(
1408            &step.left, &step.right, &mut aligned, &weights, step_scoring, &gap, use_fft,
1409            &mut profile_cache, constraints, penalize_term_gaps, memsave_dp, c_compat,
1410            use_cache,
1411        );
1412
1413        let width = aligned[step.left[0]].len().max(aligned[step.right[0]].len());
1414        step_trace.push(StepTrace {
1415            clus1: step.left.len(),
1416            clus2: step.right.len(),
1417            width,
1418            score: last_score,
1419        });
1420        if std::env::var("MAFFT_DEBUG_STEPS").is_ok() {
1421            eprintln!("RDBG {} {} {} {} {:.4}",
1422                step_idx, step.left.len(), step.right.len(), width, last_score);
1423        }
1424        if let Ok(f) = std::env::var("RS_PROGRESSIVE_TRACE") {
1425            use std::io::Write;
1426            if let Ok(mut fp) = std::fs::OpenOptions::new().create(true).append(true).open(&f) {
1427                let m1 = step.left[0];
1428                let m2 = step.right[0];
1429                let mut h: u64 = 5381;
1430                for &i in &step.left {
1431                    for &c in &aligned[i] { h = h.wrapping_mul(33).wrapping_add(c as u64); }
1432                }
1433                for &i in &step.right {
1434                    for &c in &aligned[i] { h = h.wrapping_mul(33).wrapping_add(c as u64); }
1435                }
1436                let _ = writeln!(fp, "step={} m1={} m2={} clus1={} clus2={} width={} pscore={:.6} hash={:x}",
1437                    step_idx, m1, m2, step.left.len(), step.right.len(), width, last_score, h);
1438            }
1439        }
1440        if std::env::var("RDBG_PT_STEPS").is_ok() {
1441            eprintln!("RDBG_PT step={} clus1={} clus2={} width={} mem1={:?} mem2={:?}",
1442                step_idx, step.left.len(), step.right.len(), width, step.left, step.right);
1443        }
1444        // BB30013 cpmxhist diagnostic: dump the cached profile for this
1445        // step's output cluster after the merge writes to cache. Matches
1446        // C's `disttbfast.c::treebase` cpmxhist dump at the same point.
1447        if let Ok(prefix) = std::env::var("MAFFT_DUMP_CPMX_PREFIX") {
1448            let mut key = step.left.clone();
1449            key.extend_from_slice(&step.right);
1450            key.sort_unstable();
1451            if let Some(cached) = profile_cache.get(&key) {
1452                let fname = format!("{}_step_{}.txt", prefix, step_idx);
1453                if let Ok(mut f) = std::fs::File::create(&fname) {
1454                    use std::io::Write;
1455                    let prof = &cached.profile;
1456                    let cw = prof.length;
1457                    let na = prof.nalphabets;
1458                    writeln!(f, "step={} width={} nalphabets={} clus1={} clus2={} score={:.6}",
1459                        step_idx, cw, na, step.left.len(), step.right.len(), last_score).unwrap();
1460                    for k in 0..na {
1461                        write!(f, "F[{}]:", k).unwrap();
1462                        for j in 0..cw {
1463                            write!(f, " {:.18e}", prof.freqs[j][k]).unwrap();
1464                        }
1465                        writeln!(f).unwrap();
1466                    }
1467                    // C's `gapfreq*pt` stores `nongap_freq` (= 1.0 - gap_freq);
1468                    // see `Salignmm.c:1495,1519` for the post-gapcountf flip.
1469                    // Rust caches `nongap_freq` of length `cw` and sets
1470                    // `nongap_freq[cw] = 1.0` implicitly in the DP. To match
1471                    // C's cpmxhist[nalphabets] which has length cw+1, we
1472                    // emit cw nongap_freq values + the implied 1.0 terminator.
1473                    write!(f, "G:").unwrap();
1474                    for j in 0..cw {
1475                        write!(f, " {:.18e}", prof.nongap_freq[j]).unwrap();
1476                    }
1477                    write!(f, " {:.18e}", 1.0).unwrap();
1478                    writeln!(f).unwrap();
1479                    write!(f, "O:").unwrap();
1480                    for j in 0..cw {
1481                        write!(f, " {:.18e}", prof.ogcp[j]).unwrap();
1482                    }
1483                    writeln!(f).unwrap();
1484                    write!(f, "N:").unwrap();
1485                    for j in 0..cw {
1486                        write!(f, " {:.18e}", prof.fgcp[j]).unwrap();
1487                    }
1488                    writeln!(f).unwrap();
1489                }
1490            }
1491        }
1492    }
1493
1494    let max_width = aligned.iter().map(|s| s.len()).max().unwrap_or(0);
1495    for seq in &mut aligned {
1496        seq.resize(max_width, b'-');
1497    }
1498
1499    MultipleAlignment {
1500        sequences: aligned, names: names.to_vec(), score: last_score, step_trace,
1501        guide_tree: None, first_pass_sequences: None, distance_matrix: None,
1502    }
1503}
1504
1505/// Merge two pre-formed groups within an existing alignment using the
1506/// progressive-style Falign (kobetsubunkatsu=0) path — same per-step
1507/// merge that `progressive_align_full_c_compat_ex` invokes.
1508///
1509/// This is what C's `dooneiteration` (`disttbfast.c:2390-2452`) uses
1510/// to realign a singleton-vs-rest split. Mirrors that with
1511/// `constraints = None`, no profile cache, no cpmx memo
1512/// (`c_compat = false`, `use_cache = false`, matching
1513/// `disttbfast.c:2288-2289`'s `cpmxchild0/1 = NULL` reset on every
1514/// `dooneiteration` step).
1515///
1516/// Returns the score; modifies `aligned` in place — only indices
1517/// in `group1 ∪ group2` are rewritten.
1518pub fn merge_two_groups_progressive(
1519    group1: &[usize],
1520    group2: &[usize],
1521    aligned: &mut Vec<Vec<u8>>,
1522    weights: &[f64],
1523    scoring: &ScoringContext,
1524    gap: &GapModel,
1525    use_fft: bool,
1526    penalize_term_gaps: bool,
1527) -> f64 {
1528    let mut empty_cache: BTreeMap<Vec<usize>, CachedProfile> = BTreeMap::new();
1529    merge_step_cached(
1530        group1, group2, aligned, weights, scoring, gap, use_fft,
1531        &mut empty_cache,
1532        None,                  // no constraints
1533        penalize_term_gaps,
1534        false,                 // memsave_dp off
1535        false,                 // c_compat off — dooneiteration always rebuilds cpmx
1536        false,                 // use_cache off — same reason
1537    )
1538}
1539
1540fn merge_step_cached(
1541    group1: &[usize],
1542    group2: &[usize],
1543    aligned: &mut Vec<Vec<u8>>,
1544    weights: &[f64],
1545    scoring: &ScoringContext,
1546    gap: &GapModel,
1547    use_fft: bool,
1548    cache: &mut BTreeMap<Vec<usize>, CachedProfile>,
1549    constraints: Option<&mafft_types::LocalHomologyTable>,
1550    penalize_term_gaps: bool,
1551    memsave_dp: bool,
1552    c_compat: bool,
1553    use_cache: bool,
1554) -> f64 {
1555    let width1 = aligned[group1[0]].len();
1556    let width2 = aligned[group2[0]].len();
1557
1558    // RS_DP_DUMP: when set, append one entry per merge call to the file at
1559    // its value. Format (tab-delimited per line):
1560    //   step_idx<TAB>group1_indices<TAB>group2_indices<TAB>weights1<TAB>
1561    //   weights2<TAB>penalty<TAB>penalty_ex<TAB>headgp<TAB>tailgp<TAB>
1562    //   group1_seqs (semicolon-sep)<TAB>group2_seqs<TAB>... blank for output;
1563    //   a second line per call is written AFTER profile_align with the output
1564    //   alignment seqs. Used by R-1-residual investigation.
1565    let dump_inputs = std::env::var_os("RS_DP_DUMP").is_some();
1566    let dp_dump_step = if dump_inputs {
1567        let s1: Vec<String> = group1.iter().map(|&i| String::from_utf8_lossy(&aligned[i]).into_owned()).collect();
1568        let s2: Vec<String> = group2.iter().map(|&i| String::from_utf8_lossy(&aligned[i]).into_owned()).collect();
1569        let w1: Vec<f64> = group1.iter().map(|&i| weights[i]).collect();
1570        let w2: Vec<f64> = group2.iter().map(|&i| weights[i]).collect();
1571        Some((s1, s2, w1, w2))
1572    } else { None };
1573
1574    // Look up cached profiles or build from sequences
1575    let key1 = sorted_key(group1);
1576    let key2 = sorted_key(group2);
1577
1578    // C-compat path: when enabled, build prof1 via `from_aligned_with_memo`
1579    // so the thread-local CPMX_MEMO incrementally updates when conditions
1580    // match C's `reuseprofiles` (Salignmm.c:1446-1450). For C, only the
1581    // FIRST cluster (cluster1) participates in the memo — cluster2 is
1582    // always rebuilt from scratch (`cpmx_calc_new(seq2, ...)` at
1583    // Salignmm.c:1555). Mirror that asymmetry here.
1584    // C MAFFT only uses the cpmxhist cache in pass 0 (`treebase`); pass 1+
1585    // (`dooneiteration`) sets `cpmxchild0/1 = NULL` and falls through to
1586    // `cpmx_calc_new` (Salignmm.c:1473-1505 fallback path,
1587    // disttbfast.c:2288-2289). The cached blend (`createcpmxresult`) omits
1588    // the eff*1.0 gap-insertion contribution to `cpmx[24][j]` ("tsukawanai"
1589    // comment at Salignmm.c:624), so reusing it across passes diverges from
1590    // a fresh build — this surfaces as the BB20018 / BB40046 step-50
1591    // pass-1 divergences. `use_cache=false` here forces fresh
1592    // `from_aligned` rebuilds in refinement passes to match C.
1593    let try_cache = use_cache;
1594    let (prof1, eff1) = if try_cache && cache.get(&key1).is_some() {
1595        let cached = cache.get(&key1).unwrap();
1596        (cached.profile.clone(), cached.eff)
1597    } else if c_compat {
1598        build_profile_with_memo(group1, aligned, weights, scoring)
1599    } else {
1600        let (prof, eff) = build_profile_from_seqs(group1, aligned, weights, scoring);
1601        (prof, eff)
1602    };
1603
1604    let (prof2, eff2) = if try_cache && cache.get(&key2).is_some() {
1605        let cached = cache.get(&key2).unwrap();
1606        (cached.profile.clone(), cached.eff)
1607    } else {
1608        let (prof, eff) = build_profile_from_seqs(group2, aligned, weights, scoring);
1609        (prof, eff)
1610    };
1611
1612
1613    // C uses Falign (FFT-accelerated) for ALL steps when ffttry is true
1614    // (nlen > clus, which is always true). G__align11 is only used when
1615    // FFT is disabled (use_fft=false) and both groups are single sequences.
1616    // When alg='A', the non-FFT fallback is A__align (= profile_align).
1617    //
1618    // C's disttbfast passes `outgap, outgap` for headgp/tailgp in G__align11
1619    // and A__align. With the -O flag (always set by mafft script), outgap=0,
1620    // which means no penalty is applied to terminal gaps (TERMGAPFAC=0).
1621    let aln = if !use_fft && group1.len() == 1 && group2.len() == 1
1622        && constraints.is_none()
1623    {
1624        // G__align11 path: flat gap penalty, character-level scoring.
1625        // Only used when FFT is disabled and no constraints. With constraints
1626        // (L-INS-i / E-INS-i / G-INS-i), single-vs-single merges still need
1627        // the impmtx contribution per cell — fall through to the constrained
1628        // profile DP below.
1629        //
1630        // `head_gap` / `tail_gap` must mirror C's `outgap` (= `penalize_term_gaps`).
1631        // For `--parttree --nofft`, the script omits `-O` so `outgap=1`
1632        // (`scripts/mafft:2655`, `splittbfast.c:560`), meaning every per-pair
1633        // merge — including 1-vs-1 — must penalize terminal gaps. Hardcoding
1634        // `false` here caused the `--parttree --nofft` 944-line divergence vs C
1635        // (every 1-vs-1 merge took the wrong head/tail gap path).
1636        // Pass `scoring.gap.extend` so the 1-vs-1 NW DP applies
1637        // `fpenalty_ex` per cell (port of C `Galign11.c:1362,1383`).
1638        // Without it, `--nofft --exp > 0` diverged at tied-trace
1639        // gap positions (R-1b closure).
1640        pairwise_align11_ex(
1641            &aligned[group1[0]], &aligned[group2[0]],
1642            &scoring.consweight_matrix, &scoring.amino_map,
1643            scoring.gap.open as f64, scoring.gap.extend as f64,
1644            penalize_term_gaps, penalize_term_gaps,
1645        )
1646    } else if use_fft {
1647        // C uses Falign for ALL steps when use_fft=true (ffttry = nlen > clus,
1648        // always true). No minimum profile length check.
1649        //
1650        // For protein scoring matrices C uses 2-channel polarity+volume FFT
1651        // via `seq_vec_2` (`Falign.c:342-348`). Build per-internal-index
1652        // polarity/volume vectors so `find_fft_anchors` can mirror that.
1653        let property_channels = if scoring.seq_type.is_nucleotide() {
1654            None
1655        } else {
1656            let nscored = scoring.nscoredalphabets;
1657            let mut polarity_by_idx = vec![0.0f64; nscored];
1658            let mut volume_by_idx = vec![0.0f64; nscored];
1659            for ch in 0u16..256 {
1660                let idx = scoring.amino_map[ch as usize] as usize;
1661                if idx < nscored {
1662                    polarity_by_idx[idx] = scoring.polarity[ch as usize];
1663                    volume_by_idx[idx] = scoring.volume[ch as usize];
1664                }
1665            }
1666            Some((polarity_by_idx, volume_by_idx))
1667        };
1668        let fft_params = FftAlignParams {
1669            num_candidates: 20,
1670            segment_params: if scoring.seq_type.is_nucleotide() {
1671                mafft_fft::SegmentParams::dna()
1672            } else {
1673                mafft_fft::SegmentParams::protein()
1674            },
1675            gap: gap.clone(),
1676            // C `Falign.c:686-687` sets the per-segment `headgp/tailgp`
1677            // to the global `outgap` for the first/last segment. So
1678            // when outgap=1 (term gaps penalized — G-INS-i, --parttree),
1679            // we set head_gap/tail_gap=true. Threaded via
1680            // `penalize_term_gaps`.
1681            head_gap: penalize_term_gaps,
1682            tail_gap: penalize_term_gaps,
1683            num_channels: scoring.nscoredalphabets,
1684            property_channels,
1685        };
1686        fft_profile_align(&prof1, &prof2, &scoring.consweight_matrix, &fft_params)
1687    } else if let Some(table) = constraints {
1688        // Constraint-aware progressive merge (L-INS-i / E-INS-i tbfast path).
1689        // Build per-cell impmtx from the localhom table over the group split,
1690        // then call the importance-aware DP. Mirrors C's `partA__align` /
1691        // `Falign_localhom` per-segment DP with `imp_match_out_vead` adding
1692        // the importance bonus row-by-row.
1693        let g1_seq_refs: Vec<&[u8]> = group1.iter().map(|&i| aligned[i].as_slice()).collect();
1694        let g2_seq_refs: Vec<&[u8]> = group2.iter().map(|&i| aligned[i].as_slice()).collect();
1695        // Group-local sum-1 normalized weights (matches C's
1696        // `fastconjuction_noname` `peff[m] /= total`, tddis.c:552-556).
1697        const MINIMUM_WEIGHT: f64 = 0.00001;
1698        let w1: Vec<f64> = group1.iter().map(|&i| weights[i].max(MINIMUM_WEIGHT)).collect();
1699        let w2: Vec<f64> = group2.iter().map(|&i| weights[i].max(MINIMUM_WEIGHT)).collect();
1700        let s1: f64 = w1.iter().sum();
1701        let s2: f64 = w2.iter().sum();
1702        let w1n: Vec<f64> = if s1 > 0.0 { w1.iter().map(|w| w / s1).collect() } else { vec![1.0; group1.len()] };
1703        let w2n: Vec<f64> = if s2 > 0.0 { w2.iter().map(|w| w / s2).collect() } else { vec![1.0; group2.len()] };
1704        let imp = mafft_align::build_imp_matrix(
1705            table,
1706            group1, group2,
1707            &g1_seq_refs, &g2_seq_refs,
1708            &w1n, &w2n,
1709            prof1.length, prof2.length,
1710            mafft_align::FASTATHRESHOLD_DEFAULT,
1711        );
1712        if std::env::var_os("RUST_IMP_DUMP").is_some() {
1713            let s00 = imp.first().and_then(|r| r.first()).copied().unwrap_or(0.0);
1714            let s100 = if imp.len()>100 && imp[100].len()>100 { imp[100][100] } else { 0.0 };
1715            let s300 = if imp.len()>300 && imp[300].len()>300 { imp[300][300] } else { 0.0 };
1716            eprintln!("[RUST_IMP] g1={:?} g2={:?} lgth1={} lgth2={} eff1={:?} eff2={:?} imp[0,0]={:.4} imp[100,100]={:.4} imp[300,300]={:.4}",
1717                group1, group2, prof1.length, prof2.length, w1n, w2n, s00, s100, s300);
1718            for &gi in group1 {
1719                for &gj in group2 {
1720                    let regs = table.get(gi, gj);
1721                    for (idx, r) in regs.iter().enumerate().take(3) {
1722                        eprintln!("[RUST_IMP] lh[{},{}] e{}: opt={:.6} imp={:.6} overlapaa={} s1={} e1={} s2={} e2={}",
1723                            gi, gj, idx, r.opt, r.importance, r.overlapaa, r.start1, r.end1, r.start2, r.end2);
1724                    }
1725                }
1726            }
1727        }
1728        mafft_align::profile_align_imp(
1729            &prof1, &prof2, &scoring.consweight_matrix, gap,
1730            penalize_term_gaps, penalize_term_gaps, Some(&imp),
1731        )
1732    } else if memsave_dp {
1733        // `--memsave`: route through the Hirschberg DP. Mirrors C
1734        // MAFFT's `MSalignmm` (`tbfast.c:1159-1161` under `alg='M'`).
1735        // For inputs that fit in memory, the alignment is the same
1736        // as `profile_align` — only memory layout differs.
1737        mafft_align::msalignmm(
1738            &prof1, &prof2, &scoring.consweight_matrix, gap,
1739            penalize_term_gaps, penalize_term_gaps,
1740        )
1741    } else {
1742        // Non-FFT, no-constraints fallback (`--nofft` path or single-vs-
1743        // single without constraints). C's `outgap` flows through here
1744        // via `penalize_term_gaps`: false → outgap=0 (term-gap free,
1745        // FFT-NS-2/L-INS-i/E-INS-i defaults), true → outgap=1
1746        // (G-INS-i and `--parttree`).
1747        profile_align(
1748            &prof1, &prof2, &scoring.consweight_matrix, gap,
1749            penalize_term_gaps, penalize_term_gaps,
1750        )
1751    };
1752
1753    // Build gaptables for profile caching (matching C's gaptable1/gaptable2)
1754    let new_width = aln.operations.len();
1755    let mut gaptable1 = Vec::with_capacity(new_width); // 'o' = content, '-' = gap
1756    let mut gaptable2 = Vec::with_capacity(new_width);
1757    for op in &aln.operations {
1758        match op {
1759            AlignOp::Match => { gaptable1.push(b'o'); gaptable2.push(b'o'); }
1760            AlignOp::Delete => { gaptable1.push(b'o'); gaptable2.push(b'-'); }
1761            AlignOp::Insert => { gaptable1.push(b'-'); gaptable2.push(b'o'); }
1762        }
1763    }
1764
1765    // Cache the merged profile (C's createcpmxresult + creategapfreqresult +
1766    // createogresult + createfgresult). Only cache for groups > 20 sequences
1767    // (matching C's condition at MSalignmm.c line 2431).
1768    let total_eff = eff1 + eff2;
1769    let combined_seqs = group1.len() + group2.len();
1770    if total_eff > 0.0 && combined_seqs > 20 {
1771        let norm_eff1 = eff1 / total_eff;
1772        let norm_eff2 = eff2 / total_eff;
1773        let merged_prof = blend_profiles_exact(
1774            &prof1, &prof2,
1775            norm_eff1, norm_eff2,
1776            &gaptable1, &gaptable2,
1777            scoring.nalphabets,
1778        );
1779        let mut merged_key = group1.to_vec();
1780        merged_key.extend_from_slice(group2);
1781        merged_key.sort();
1782        cache.insert(merged_key, CachedProfile {
1783            profile: merged_prof,
1784            eff: total_eff,
1785        });
1786    }
1787
1788    // Remove child caches (they won't be needed again)
1789    cache.remove(&key1);
1790    cache.remove(&key2);
1791
1792    // Build new sequences for group1 and group2 ONLY
1793    let mut cursor1 = 0usize;
1794    let mut cursor2 = 0usize;
1795    let mut new_seqs_g1: Vec<Vec<u8>> = vec![Vec::with_capacity(new_width); group1.len()];
1796    let mut new_seqs_g2: Vec<Vec<u8>> = vec![Vec::with_capacity(new_width); group2.len()];
1797
1798    for op in &aln.operations {
1799        match op {
1800            AlignOp::Match => {
1801                for (gi, &idx) in group1.iter().enumerate() {
1802                    new_seqs_g1[gi].push(if cursor1 < width1 { aligned[idx][cursor1] } else { b'-' });
1803                }
1804                for (gi, &idx) in group2.iter().enumerate() {
1805                    new_seqs_g2[gi].push(if cursor2 < width2 { aligned[idx][cursor2] } else { b'-' });
1806                }
1807                cursor1 += 1;
1808                cursor2 += 1;
1809            }
1810            AlignOp::Delete => {
1811                for (gi, &idx) in group1.iter().enumerate() {
1812                    new_seqs_g1[gi].push(if cursor1 < width1 { aligned[idx][cursor1] } else { b'-' });
1813                }
1814                for gi in 0..group2.len() { new_seqs_g2[gi].push(b'-'); }
1815                cursor1 += 1;
1816            }
1817            AlignOp::Insert => {
1818                for gi in 0..group1.len() { new_seqs_g1[gi].push(b'-'); }
1819                for (gi, &idx) in group2.iter().enumerate() {
1820                    new_seqs_g2[gi].push(if cursor2 < width2 { aligned[idx][cursor2] } else { b'-' });
1821                }
1822                cursor2 += 1;
1823            }
1824        }
1825    }
1826
1827    for (gi, &idx) in group1.iter().enumerate() { aligned[idx] = new_seqs_g1[gi].clone(); }
1828    for (gi, &idx) in group2.iter().enumerate() { aligned[idx] = new_seqs_g2[gi].clone(); }
1829
1830    // RS_DP_DUMP: write per-step inputs + outputs to the file at the env
1831    // var's value. Format (one tab-separated entry per line):
1832    //   group1<TAB>group2<TAB>w1<TAB>w2<TAB>penalty<TAB>penalty_ex<TAB>
1833    //   headgp<TAB>tailgp<TAB>use_fft<TAB>has_constraint<TAB>out1<TAB>out2
1834    // Where group{1,2} are semicolon-separated input row sequences,
1835    // out{1,2} are semicolon-separated post-merge row sequences.
1836    if let Some((s1, s2, w1, w2)) = dp_dump_step {
1837        if let Ok(path) = std::env::var("RS_DP_DUMP") {
1838            use std::io::Write;
1839            if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
1840                let out1: Vec<String> = new_seqs_g1.iter().map(|v| String::from_utf8_lossy(v).into_owned()).collect();
1841                let out2: Vec<String> = new_seqs_g2.iter().map(|v| String::from_utf8_lossy(v).into_owned()).collect();
1842                let _ = writeln!(f,
1843                    "g1={}\tg2={}\tw1={}\tw2={}\tpen={}\tpen_ex={}\thgp={}\ttgp={}\tfft={}\tcon={}\tout1={}\tout2={}",
1844                    s1.join(";"), s2.join(";"),
1845                    w1.iter().map(|x| format!("{:.10}", x)).collect::<Vec<_>>().join(","),
1846                    w2.iter().map(|x| format!("{:.10}", x)).collect::<Vec<_>>().join(","),
1847                    gap.open as i32, gap.extend as i32,
1848                    penalize_term_gaps as u8, penalize_term_gaps as u8,
1849                    use_fft as u8,
1850                    constraints.is_some() as u8,
1851                    out1.join(";"), out2.join(";"),
1852                );
1853            }
1854        }
1855    }
1856
1857    aln.score
1858}
1859
1860fn sorted_key(group: &[usize]) -> Vec<usize> {
1861    let mut k = group.to_vec();
1862    k.sort();
1863    k
1864}
1865
1866fn build_profile_from_seqs(
1867    group: &[usize],
1868    aligned: &[Vec<u8>],
1869    weights: &[f64],
1870    scoring: &ScoringContext,
1871) -> (Profile, f64) {
1872    let seqs: Vec<&[u8]> = group.iter().map(|&i| aligned[i].as_slice()).collect();
1873    // C normalizes weights to sum to 1.0 within each group for cpmx_calc_new,
1874    // then tracks orieff (= raw sum) separately for createcpmxresult blending.
1875    let w: Vec<f64> = group.iter().map(|&i| weights[i]).collect();
1876    let sum: f64 = w.iter().sum();
1877    let wn: Vec<f64> = if sum > 0.0 { w.iter().map(|v| v / sum).collect() } else { vec![1.0; group.len()] };
1878    let prof = Profile::from_aligned(&seqs, &wn, &scoring.amino_map, scoring.nalphabets);
1879    (prof, sum)
1880}
1881
1882/// `--c-compat` variant of `build_profile_from_seqs` that uses
1883/// `Profile::from_aligned_with_memo` so the thread-local cpmx memo
1884/// can fire when conditions match. `firstmem` is `group[0]` (the
1885/// global leaf index of the cluster's first member, matching C's
1886/// `localmem[0][0]`); `icyc` is the cluster size; `lgth` is the
1887/// per-sequence width.
1888fn build_profile_with_memo(
1889    group: &[usize],
1890    aligned: &[Vec<u8>],
1891    weights: &[f64],
1892    scoring: &ScoringContext,
1893) -> (Profile, f64) {
1894    let seqs: Vec<&[u8]> = group.iter().map(|&i| aligned[i].as_slice()).collect();
1895    let w: Vec<f64> = group.iter().map(|&i| weights[i]).collect();
1896    let sum: f64 = w.iter().sum();
1897    let wn: Vec<f64> = if sum > 0.0 { w.iter().map(|v| v / sum).collect() } else { vec![1.0; group.len()] };
1898    let firstmem = group[0] as i32;
1899    let icyc = group.len();
1900    let lgth = seqs.first().map_or(0, |s| s.len());
1901    let prof = Profile::from_aligned_with_memo(
1902        &seqs, &wn, &scoring.amino_map, scoring.nalphabets,
1903        firstmem, icyc, lgth,
1904    );
1905    (prof, sum)
1906}
1907
1908/// Blend two profiles using C's exact createcpmxresult + creategapfreqresult +
1909/// createogresult + createfgresult logic (MSalignmm.c lines 283-467).
1910///
1911/// The ogcp/fgcp blending handles gap positions specially: at block boundaries
1912/// (gap→non-gap or non-gap→gap), the value is interpolated from the source
1913/// profile's nongap_freq. Within a gap block, the value is 0.
1914/// Blend two profiles. Exposed `pub` for FFI cross-validation against
1915/// C's `createcpmxresult + creategapfreqresult + createogresult +
1916/// createfgresult` (`Salignmm.c:608-823`).
1917pub fn blend_profiles_exact(
1918    prof1: &Profile,
1919    prof2: &Profile,
1920    eff1: f64,
1921    eff2: f64,
1922    gaptable1: &[u8],
1923    gaptable2: &[u8],
1924    nalphabets: usize,
1925) -> Profile {
1926    let alen = gaptable1.len();
1927    let mut freqs = vec![vec![0.0f64; nalphabets]; alen];
1928    let mut nongap_freq = vec![0.0f64; alen + 1]; // C uses alen+1
1929    let mut ogcp = vec![0.0f64; alen];
1930    let mut fgcp = vec![0.0f64; alen];
1931
1932    // createcpmxresult: blend frequency matrices.
1933    // FMA throughout: matches gcc's `-O3` fusion of `a + b*c` so the blended
1934    // child-profile is bit-identical to C's. Without FMA the per-column
1935    // weighted frequency accumulates 1-ULP differences that propagate into
1936    // match_calc_row and flip DP tie-breaks for flat-landscape matrices
1937    // (TM PAM 200 — §B.2).
1938    {
1939        let mut p = 0usize;
1940        for j in 0..alen {
1941            if gaptable1[j] != b'-' {
1942                if p < prof1.length {
1943                    for k in 0..nalphabets.min(prof1.freqs[p].len()) {
1944                        freqs[j][k] = fmadd(prof1.freqs[p][k], eff1, freqs[j][k]);
1945                    }
1946                }
1947                p += 1;
1948            }
1949        }
1950    }
1951    {
1952        let mut p = 0usize;
1953        for j in 0..alen {
1954            if gaptable2[j] != b'-' {
1955                if p < prof2.length {
1956                    for k in 0..nalphabets.min(prof2.freqs[p].len()) {
1957                        freqs[j][k] = fmadd(prof2.freqs[p][k], eff2, freqs[j][k]);
1958                    }
1959                }
1960                p += 1;
1961            }
1962        }
1963    }
1964
1965    // creategapfreqresult: blend nongap frequencies (C uses alen+1 positions)
1966    {
1967        let mut p = 0usize;
1968        for j in 0..=alen {
1969            if j < alen && gaptable1[j] == b'-' {
1970                // gap position: skip
1971            } else {
1972                if p < prof1.nongap_freq.len() {
1973                    nongap_freq[j] = fmadd(prof1.nongap_freq[p], eff1, nongap_freq[j]);
1974                }
1975                p += 1;
1976            }
1977        }
1978    }
1979    {
1980        let mut p = 0usize;
1981        for j in 0..alen {
1982            if gaptable2[j] == b'-' {
1983                // gap position: skip
1984            } else {
1985                if p < prof2.nongap_freq.len() {
1986                    nongap_freq[j] = fmadd(prof2.nongap_freq[p], eff2, nongap_freq[j]);
1987                }
1988                p += 1;
1989            }
1990        }
1991    }
1992    nongap_freq[alen] = 1.0; // C: gapfresult[j] = 1.0 at tail
1993
1994    // createogresult: blend opening gap counts with block-boundary handling
1995    blend_og_one_side(&mut ogcp, &prof1.ogcp, &prof1.nongap_freq, gaptable1, eff1);
1996    blend_og_one_side(&mut ogcp, &prof2.ogcp, &prof2.nongap_freq, gaptable2, eff2);
1997
1998    // createfgresult: blend closing gap counts with block-boundary handling
1999    blend_fg_one_side(&mut fgcp, &prof1.fgcp, &prof1.nongap_freq, gaptable1, eff1);
2000    blend_fg_one_side(&mut fgcp, &prof2.fgcp, &prof2.nongap_freq, gaptable2, eff2);
2001
2002    // Compute gap_freq from nongap_freq
2003    let gap_freq: Vec<f64> = nongap_freq[..alen].iter().map(|&nf| (1.0 - nf).max(0.0)).collect();
2004    let nongap_freq_trimmed = nongap_freq[..alen].to_vec();
2005
2006    Profile {
2007        freqs,
2008        gap_freq,
2009        nongap_freq: nongap_freq_trimmed,
2010        ogcp,
2011        fgcp,
2012        length: alen,
2013        nalphabets,
2014    }
2015}
2016
2017/// C's createogresult logic for one side (MSalignmm.c lines 354-378).
2018fn blend_og_one_side(
2019    result: &mut [f64],
2020    ori: &[f64],     // raw opening counts
2021    gf: &[f64],      // nongap_freq
2022    gaptable: &[u8],
2023    eff: f64,
2024) {
2025    let alen = result.len();
2026    let mut p = 0usize;
2027    for j in 0..alen {
2028        if gaptable[j] == b'-' {
2029            if j == 0 {
2030                result[j] += eff;
2031            } else if gaptable[j - 1] != b'-' && p > 0 {
2032                let gf_val = if p - 1 < gf.len() { gf[p - 1] } else { 1.0 };
2033                result[j] = fmadd(gf_val, eff, result[j]);
2034            }
2035        } else {
2036            if j == 0 || (j > 0 && gaptable[j - 1] != b'-') {
2037                if p < ori.len() {
2038                    result[j] = fmadd(ori[p], eff, result[j]);
2039                }
2040            }
2041            p += 1;
2042        }
2043    }
2044}
2045
2046/// C's createfgresult logic for one side (Salignmm.c lines 782-823).
2047///
2048/// IMPORTANT: C reads `gaptable1[j+1]` even at j = alen-1, accessing
2049/// the null terminator past the end of the C string (which is `!= '-'`).
2050/// So at the LAST non-gap position, C ALWAYS adds `ori[p] * eff` (the
2051/// closing-count contribution), since the "next" position is treated as
2052/// non-gap. Our Rust gaptable is a `&[u8]` without a null terminator,
2053/// so we treat the out-of-bounds j+1 as non-gap to match. The prior
2054/// `j < alen - 1` short-circuit dropped this contribution at the
2055/// alignment's last position, causing pass-1 BB20027 cpmx drift when
2056/// the merged profile was cached and reused at the next merge.
2057fn blend_fg_one_side(
2058    result: &mut [f64],
2059    ori: &[f64],     // raw closing counts
2060    gf: &[f64],      // nongap_freq
2061    gaptable: &[u8],
2062    eff: f64,
2063) {
2064    let alen = result.len();
2065    let mut p = 0usize;
2066    // C treats out-of-bounds `gaptable[alen]` as non-gap (the `\0` of
2067    // the null-terminated string, which is != '-').
2068    let next_is_gap = |j: usize| -> bool {
2069        if j + 1 < alen { gaptable[j + 1] == b'-' } else { false }
2070    };
2071    for j in 0..alen {
2072        if gaptable[j] == b'-' {
2073            if j == alen - 1 {
2074                result[j] += eff;
2075            } else if !next_is_gap(j) {
2076                let gf_val = if p < gf.len() { gf[p] } else { 1.0 };
2077                result[j] = fmadd(gf_val, eff, result[j]);
2078            }
2079        } else {
2080            if !next_is_gap(j) {
2081                if p < ori.len() {
2082                    result[j] = fmadd(ori[p], eff, result[j]);
2083                }
2084            }
2085            p += 1;
2086        }
2087    }
2088}
2089
2090#[cfg(test)]
2091mod tests {
2092    use super::*;
2093    use mafft_tree::{DistanceMatrix, upgma};
2094    use mafft_scoring::build_context;
2095    use mafft_types::{ScoringModel, SeqType};
2096
2097    fn check_alignment(result: &MultipleAlignment, original: &[Vec<u8>]) {
2098        let width = result.width();
2099        assert!(width > 0);
2100        for (i, seq) in result.sequences.iter().enumerate() {
2101            assert_eq!(seq.len(), width, "seq {i} wrong width: {} vs {width}", seq.len());
2102            let ungapped: Vec<u8> = seq.iter().filter(|&&c| c != b'-').cloned().collect();
2103            assert_eq!(ungapped, original[i], "seq {i} residues not preserved");
2104        }
2105    }
2106
2107    #[test]
2108    fn progressive_two_identical() {
2109        let scoring = build_context(ScoringModel::Blosum(62), SeqType::Protein);
2110        let seqs = vec![b"ACDEFGHIK".to_vec(), b"ACDEFGHIK".to_vec()];
2111        let names = vec!["s1".into(), "s2".into()];
2112        let mut dm = DistanceMatrix::new(2);
2113        dm.set(0, 1, 0.0);
2114        let topo = upgma(&dm);
2115        let result = progressive_align(&seqs, &names, &topo, &scoring, false, None);
2116        assert_eq!(result.sequences[0], result.sequences[1]);
2117        check_alignment(&result, &seqs);
2118    }
2119
2120    #[test]
2121    fn progressive_three_sequences() {
2122        let scoring = build_context(ScoringModel::Blosum(62), SeqType::Protein);
2123        let seqs = vec![
2124            b"ACDEFGHIK".to_vec(),
2125            b"ACDEFHIK".to_vec(),
2126            b"ACDHIK".to_vec(),
2127        ];
2128        let names = vec!["s1".into(), "s2".into(), "s3".into()];
2129        let mut dm = DistanceMatrix::new(3);
2130        dm.set(0, 1, 0.1); dm.set(0, 2, 0.3); dm.set(1, 2, 0.2);
2131        let topo = upgma(&dm);
2132        let result = progressive_align(&seqs, &names, &topo, &scoring, false, None);
2133        check_alignment(&result, &seqs);
2134    }
2135
2136    #[test]
2137    fn progressive_six_sequences_preserves_residues() {
2138        let scoring = build_context(ScoringModel::Blosum(62), SeqType::Protein);
2139        let seqs = vec![
2140            b"ACDEFGHIKLMNPQR".to_vec(),
2141            b"ACDEFHIKLMNPQR".to_vec(),
2142            b"ACDEHIKLMNPQR".to_vec(),
2143            b"ACDHIKLMNPQR".to_vec(),
2144            b"ACDHIKLMNP".to_vec(),
2145            b"ACDHIKLM".to_vec(),
2146        ];
2147        let names: Vec<String> = (0..6).map(|i| format!("s{i}")).collect();
2148        let mut dm = DistanceMatrix::new(6);
2149        for i in 0..6 { for j in (i+1)..6 { dm.set(i, j, (j-i) as f64 * 0.1); } }
2150        let topo = upgma(&dm);
2151        let result = progressive_align(&seqs, &names, &topo, &scoring, false, None);
2152        check_alignment(&result, &seqs);
2153    }
2154}