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