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}