mafft-core 0.2.0

Core MAFFT alignment engine: progressive alignment and iterative refinement
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
/// Progressive alignment following a guide tree.
///
/// Matches C's `treebase()` from `disttbfast.c`: at each merge step,
/// only group1 and group2 sequences are modified. "Other" sequences
/// are left untouched. Profiles are cached after each merge step
/// (`cpmxhist`) to match C's exact float accumulation order.

// §B.5 — `profile_cache` uses `BTreeMap` (not `HashMap`) for deterministic
// iteration order. The cache is currently `.get()` / `.insert()` / `.remove()`
// only, so HashMap's randomized order isn't an active hazard — but any future
// refactor adding `.iter()` / `.values()` / `.keys()` would silently produce
// non-deterministic alignment output. `BTreeMap` makes that future-safe at
// negligible cost (cache size is bounded by `nseq`, keys are small
// `Vec<usize>`).
use mafft_types::fp::fmadd;
use std::collections::BTreeMap;
use mafft_align::{profile_align, pairwise_align11_ex, fft_profile_align, Profile, GapModel, AlignOp, FftAlignParams};
use mafft_tree::{Topology, sequence_weights, compute_distfromtip};
use mafft_types::ScoringContext;

/// C `mltaln9.c::dist2offset`: offset = min(0, dist*0.5 - specificityconsideration).
/// `dist` is `2 * distfromtip` so the result is `min(0, distfromtip - sc)`.
pub(crate) fn dist2offset(dist: f64, sc: f64) -> f64 {
    let v = dist * 0.5 - sc;
    if v > 0.0 { 0.0 } else { v }
}

/// C `mltaln9.c::makedynamicmtx`. Adds `offset * 600` to every substitution
/// score where `offset = dist2offset(2 * distfromtip, sc)`. Negative for
/// shallow merges (close-related), zero for deep merges. Pulls divergent
/// regions apart at shallow merges → wider final alignment.
///
/// C IMPORTANT: `mltaln9.c:15197-15203` SKIPS cells where amino[i] or
/// amino[j] is '-' (gap_idx in our alphabet). We mirror that here — for
/// protein gap_idx = 24, for DNA gap_idx = 24 (= `b'-'` mapped). Without
/// this skip, profile DP cell scores diverge from C on the `--allowshift`
/// per-step path even when input has no gap characters, because the static
/// `amino_dynamicmtx` in C is char-indexed and the unshifted '-' row/col
/// participates in the boundary handling.
pub(crate) fn make_dynamic_matrix(base: &[Vec<f64>], distfromtip: f64, unalign_level: f64, gap_idx: usize) -> Vec<Vec<f64>> {
    let offset = dist2offset(distfromtip * 2.0, unalign_level);
    if offset == 0.0 {
        return base.iter().map(|r| r.clone()).collect();
    }
    // C `mltaln9.c::makedynamicmtx` computes `out[i][j] = in[i][j] + offset * 600`
    // per cell; arm64 clang fuses this into a single FMA, baseline x86-64
    // gcc does not (`fmadd` follows the `mafft_types::fp` policy).
    // Pre-computing `delta = offset * 600.0` then `v + delta` is two rounded ops
    // and drifts ~1 ULP per cell. See [[project_allowshift_pairwise_fp]] for the
    // BB12003 bisection. Same shape fix as `constraints.rs` `dyn_matrix` build.
    base.iter()
        .enumerate()
        .map(|(i, row)| {
            row.iter().enumerate()
                .map(|(j, &v)| {
                    if i == gap_idx || j == gap_idx { v } else { fmadd(offset, 600.0, v) }
                })
                .collect()
        })
        .collect()
}

#[derive(Debug, Clone)]
pub struct MultipleAlignment {
    pub sequences: Vec<Vec<u8>>,
    pub names: Vec<String>,
    pub score: f64,
    /// Per-merge-step trace: one entry per progressive merge. Each entry is
    /// `(clus1_size, clus2_size, width_after_merge, score)` matching C's
    /// `RDBG step clus1 clus2 width score` debug line. Used for regression
    /// tests that assert byte-level parity with C on a per-step basis;
    /// harmless to ignore.
    pub step_trace: Vec<StepTrace>,
    /// Final progressive guide tree, kept around so callers can serialize
    /// it for `--treeout` (`mltaln9.c::loadtree` and the various
    /// `fixed_musclesupg_*_treeout` variants). Populated by the engine
    /// when alignment completes; `None` for paths that don't track it
    /// (e.g. tests building an `MultipleAlignment` directly).
    pub guide_tree: Option<mafft_tree::Topology>,
    /// Aligned MSA after the FIRST retree pass, BEFORE the final pass
    /// rebuilds the guide tree and re-aligns. C MAFFT's `--parttree`
    /// runs `splittbfast` twice and feeds CALL 1's output (`pre_1`) to
    /// CALL 2 — both for `--reorder` and `--treeout`. We stash `pre_1`
    /// here so the CLI can replay CALL 2's tree generation on the right
    /// input.
    pub first_pass_sequences: Option<Vec<Vec<u8>>>,
    /// Pairwise distance matrix used by the engine for guide-tree
    /// construction. Populated when the engine knows callers will need
    /// it — currently `--distout` (writes `<input>.hat2`) and
    /// `--scoreout` (computes the unweighted SP score). `None` for
    /// paths that skip it (PartTree, --treein with a user-supplied
    /// tree, tests building a MultipleAlignment directly).
    pub distance_matrix: Option<mafft_tree::DistanceMatrix>,
}

#[derive(Debug, Clone, Copy)]
pub struct StepTrace {
    pub clus1: usize,
    pub clus2: usize,
    pub width: usize,
    pub score: f64,
}

impl MultipleAlignment {
    pub fn width(&self) -> usize {
        self.sequences.first().map_or(0, |s| s.len())
    }
    pub fn nseq(&self) -> usize {
        self.sequences.len()
    }
}

/// Cached profile from a previous merge step.
/// Stores the blended composition probability matrix, gap frequencies,
/// and opening/closing gap counts — matching C's `cpmxhist`.
#[derive(Debug, Clone)]
struct CachedProfile {
    profile: Profile,
    /// Effective weight of this group (orieff), for blending at the next merge.
    eff: f64,
}

/// Per-step branch tag for `--add` progressive alignment, mirroring
/// C `disttbfast.c::mergeoralign[]` (lines 4188-4302):
/// - `SkipExisting` ('n'): both subtrees are existing-only — the
///   alignment is already in place; do nothing.
/// - `NewLeft` ('1'): only the LEFT subtree contains a "new" sequence.
/// - `NewRight` ('2'): only the RIGHT subtree contains a "new" sequence.
/// - `Wide` ('w'): both subtrees contain new sequences — full DP merge.
#[derive(Debug, Clone, Copy)]
pub enum MergeOrAlign {
    SkipExisting,
    NewLeft,
    NewRight,
    Wide,
}

pub fn progressive_align(
    sequences: &[Vec<u8>],
    names: &[String],
    topology: &Topology,
    scoring: &ScoringContext,
    use_fft: bool,
    shift_penalty: Option<f64>,
) -> MultipleAlignment {
    progressive_align_with_constraints(
        sequences, names, topology, scoring, use_fft, shift_penalty, None, false,
    )
}

/// Run progressive alignment with all per-sequence weights set to 1.0.
/// When normalized within each cluster, this yields uniform weights
/// `1/clus_size` — mirroring C `splittbfast.c::fastconjuction_noweight`'s
/// behavior (used by `--parttree` because `splittbfast.c:6` defines
/// `WEIGHT 0`).
///
/// The standard `progressive_align` derives weights from the guide tree's
/// branch lengths via `weighting::sequence_weights` (matching `disttbfast`
/// / `tbfast`'s `fastconjuction_noname` path).
pub fn progressive_align_unweighted(
    sequences: &[Vec<u8>],
    names: &[String],
    topology: &Topology,
    scoring: &ScoringContext,
    use_fft: bool,
    shift_penalty: Option<f64>,
) -> MultipleAlignment {
    let weights = vec![1.0f64; sequences.len()];
    progressive_align_with_weights_override(
        sequences, names, topology, scoring, use_fft, shift_penalty,
        None, false, Some(&weights),
    )
}

/// Progressive alignment for `--add`-style merges, with per-step
/// `mergeoralign[]` tags driving skip/merge decisions. Mirrors C's
/// `disttbfast.c::treebase` mergeoralign-aware loop:
///   - `SkipExisting` branches: do NOTHING — both subtrees are
///     existing-only and already aligned.
///   - All other branches: standard merge_step_cached call, then
///     propagate any new gap columns inserted during the merge to
///     the OTHER existing rows (those not in left/right).
///
/// `sequences[0..n_existing]` should be the (commongappick-stripped)
/// existing alignment, all the same width. `sequences[n_existing..]`
/// are the new raw sequences. The topology is built over all
/// `n_existing + n_new` sequences.
///
/// The gap-propagation step mirrors C's `insertnewgaps_bothorders`
/// (`addfunctions.c:675`) at a coarser grain: instead of using
/// `gaplen`/`gapmap` arrays from `findnewgaps`/`findcommongaps`, we
/// observe the pre-vs-post-merge state of any active existing row to
/// compute new-gap positions and apply the same insertions to the
/// non-active existing rows. Sufficient for byte-identity when the
/// per-step common-gap strip/restore would otherwise be a no-op
/// (= no all-gap columns exist within any active existing subcluster).
pub fn progressive_align_with_mergeoralign(
    sequences: &[Vec<u8>],
    names: &[String],
    topology: &Topology,
    mergeoralign: &[MergeOrAlign],
    scoring: &ScoringContext,
    use_fft: bool,
) -> MultipleAlignment {
    progressive_align_with_mergeoralign_n(
        sequences, names, topology, mergeoralign, scoring, use_fft,
        sequences.len(), // default: treat all rows as existing
    )
}

/// Variant that takes `n_existing` explicitly so the caller can
/// distinguish existing rows (whose intra-alignment must be preserved)
/// from new rows (which can be freely re-aligned).
pub fn progressive_align_with_mergeoralign_n(
    sequences: &[Vec<u8>],
    names: &[String],
    topology: &Topology,
    mergeoralign: &[MergeOrAlign],
    scoring: &ScoringContext,
    use_fft: bool,
    n_existing: usize,
) -> MultipleAlignment {
    let nseq = sequences.len();
    if nseq == 0 {
        return MultipleAlignment {
            sequences: Vec::new(), names: Vec::new(), score: 0.0, step_trace: Vec::new(), guide_tree: None, first_pass_sequences: None, distance_matrix: None,
        };
    }
    if nseq == 1 {
        return MultipleAlignment {
            sequences: sequences.to_vec(), names: names.to_vec(), score: 0.0, step_trace: Vec::new(), guide_tree: None, first_pass_sequences: None, distance_matrix: None,
        };
    }

    let weights = sequence_weights(topology);
    let mut aligned: Vec<Vec<u8>> = sequences.to_vec();

    let mut last_score = 0.0;
    let gap = GapModel::new(scoring.gap.open as f64, scoring.gap.extend as f64);

    let mut profile_cache: BTreeMap<Vec<usize>, CachedProfile> = BTreeMap::new();

    // Track which rows are "already aligned" — i.e., have participated
    // in some merge already. Existing rows start aligned (the input
    // alignment). New rows become aligned after their first non-'n'
    // merge. Mirrors C's `alreadyaligned[]` (`disttbfast.c:2257-2258`).
    let mut already_aligned: Vec<bool> = (0..nseq).map(|i| i < n_existing).collect();

    let mut step_trace: Vec<StepTrace> = Vec::with_capacity(topology.steps.len());
    for (step_idx, step) in topology.steps.iter().enumerate() {
        let tag = mergeoralign.get(step_idx).copied().unwrap_or(MergeOrAlign::Wide);
        match tag {
            MergeOrAlign::SkipExisting => {
                let width = aligned[step.left[0]].len().max(aligned[step.right[0]].len());
                step_trace.push(StepTrace {
                    clus1: step.left.len(),
                    clus2: step.right.len(),
                    width,
                    score: 0.0,
                });
            }
            MergeOrAlign::NewRight | MergeOrAlign::NewLeft => {
                // C `disttbfast.c:2745-2756`: for case '2' (NewRight), strip
                // common gaps from group1 (the existing-only side) before the
                // merge, then restore them afterwards. The DP runs between
                // (stripped existing-side) and (full has-new-side). After the
                // merge:
                //   - existing-side and has-new-side are at post_merge_W.
                //   - Restore: re-insert the stripped common-gap columns into
                //     both sides as all-gap columns.
                //   - For OTHER aligned rows (not in either side): expand
                //     from L_pre to L_pre + N1 by inserting gap chars at the
                //     positions corresponding to new merge gaps in the
                //     existing-side representative.
                //
                // Case '1' (NewLeft) is "nai" (never reached) per
                // `disttbfast.c:2934`, but we handle it symmetrically.
                let (existing_grp, new_grp) = match tag {
                    MergeOrAlign::NewRight => (&step.left[..], &step.right[..]),
                    MergeOrAlign::NewLeft => (&step.right[..], &step.left[..]),
                    _ => unreachable!(),
                };

                let pre_width = aligned[existing_grp[0]].len();

                // findcommongaps: find columns where ALL existing_grp rows
                // are gap. These are the columns to strip.
                let pre_classification: Vec<bool> = (0..pre_width)
                    .map(|col| {
                        existing_grp.iter().all(|&i| {
                            let c = aligned[i].get(col).copied().unwrap_or(b'-');
                            c == b'-'
                        })
                    })
                    .collect();
                let n_gap_cols = pre_classification.iter().filter(|&&b| b).count();

                // Save the pre-strip representative (for OTHER reconstruction).
                let pre_rep_full = aligned[existing_grp[0]].clone();

                // commongappick(existing_grp): strip the all-gap columns.
                let pre_rep_stripped: Vec<u8>;
                if n_gap_cols > 0 {
                    pre_rep_stripped = pre_rep_full
                        .iter()
                        .enumerate()
                        .filter(|(col, _)| !pre_classification[*col])
                        .map(|(_, &c)| c)
                        .collect();
                    for &i in existing_grp {
                        let stripped: Vec<u8> = aligned[i]
                            .iter()
                            .enumerate()
                            .filter(|(col, _)| !pre_classification[*col])
                            .map(|(_, &c)| c)
                            .collect();
                        aligned[i] = stripped;
                    }
                } else {
                    pre_rep_stripped = pre_rep_full.clone();
                }
                let stripped_width = pre_rep_stripped.len();

                // Run the merge. left/right ordering preserved.
                last_score = merge_step_cached(
                    &step.left,
                    &step.right,
                    &mut aligned,
                    &weights,
                    scoring,
                    &gap,
                    use_fft,
                    &mut profile_cache,
                    None,
                    false,
                    false,
                    false, // c_compat off in --add path
                    true,  // --add: pass 0 only, cache valid
                );

                let post_merge_width = aligned[existing_grp[0]].len();
                let post_rep = aligned[existing_grp[0]].clone();

                // Identify "new merge gap" positions in the post-merge
                // existing-side representative. These are post-merge columns
                // where the merge DP inserted a gap into existing_grp's rows
                // (= positions not corresponding to any pre-strip char).
                let new_merge_gap_set = compute_new_merge_gap_set(&pre_rep_stripped, &post_rep);

                // Build mapping: stripped_idx -> pre-merge anchor positions,
                // and gap_cols_before[s] = the gap_col positions sitting
                // between the (s-1)-th and s-th anchor in pre-merge.
                let anchor_positions: Vec<usize> = (0..pre_width)
                    .filter(|&k| !pre_classification[k])
                    .collect();
                debug_assert_eq!(anchor_positions.len(), stripped_width);
                let mut gap_cols_before: Vec<Vec<usize>> =
                    vec![Vec::new(); stripped_width + 1];
                {
                    let mut s = 0usize;
                    for k in 0..pre_width {
                        if pre_classification[k] {
                            gap_cols_before[s].push(k);
                        } else {
                            s += 1;
                        }
                    }
                }

                // restorecommongaps: for both groups, insert n_gap_cols all-gap
                // columns at the right post-merge positions. For each
                // gap_col at pre-merge position k_pre with stripped_idx_after = s,
                // a gap col is inserted right BEFORE the s-th type A position
                // in post-merge (after any preceding type B's).
                if n_gap_cols > 0 {
                    let inserts_per_strip_idx: Vec<usize> =
                        gap_cols_before.iter().map(|v| v.len()).collect();
                    let active: Vec<usize> =
                        step.left.iter().chain(step.right.iter()).copied().collect();
                    for &i in &active {
                        aligned[i] = restore_common_gaps_to_merged_row(
                            &aligned[i],
                            &new_merge_gap_set,
                            &inserts_per_strip_idx,
                        );
                    }
                }

                // R-6 closure: use `apply_c_insertnewgaps` (mirrors C
                // addfunctions.c::insertnewgaps with profilealignment)
                // to do the full multi-row reconstruction. Matches C
                // byte-identically including the compression case where
                // OTHER's residue absorbs an adjacent new-merge-gap col.
                let n1 = post_merge_width - stripped_width;
                if n1 > 0 || n_gap_cols > 0 {
                    let active_set: std::collections::HashSet<usize> =
                        step.left.iter().chain(step.right.iter()).copied().collect();
                    let other_indices: Vec<usize> = (0..nseq)
                        .filter(|i| already_aligned[*i] && !active_set.contains(i))
                        .collect();

                    // Use the C-style insertnewgaps port by default.
                    // Closes R-6's `--add` adversarial divergence and
                    // matches C byte-identically on canonical inputs.
                    // RS_R6_PORT_OFF env var falls back to flat-padding
                    // for diagnostics.
                    let use_port = std::env::var("RS_R6_PORT_OFF").is_err();

                    // Sanity: only run the port if all rows that participate
                    // (active + OTHER) have consistent widths. Prior Wide
                    // steps can leave OTHER rows at different widths than
                    // the active-side post-restore width, which trips up
                    // the lockstep walker. Falling back to flat-padding
                    // for those cases keeps the canonical 30+6 fixture
                    // byte-identical.
                    let active_w = aligned[existing_grp[0]].len();
                    let other_widths_consistent = other_indices.iter().all(|&i| {
                        // OTHER should be at pre_width (before this step's
                        // common-gap restoration). pre_width is the input
                        // width of active before commongappick.
                        aligned[i].len() == pre_width
                    });

                    if use_port && !other_indices.is_empty() && other_widths_consistent {
                        // Rust uses '-' for new-merge-gaps (not '=' like C),
                        // so findnewgaps on a string would return 0. Compute
                        // gaplen + gapmap directly from new_merge_gap_set
                        // and gap_cols_before.
                        let group1_active = aligned[existing_grp[0]].clone();
                        let post_restore_w = group1_active.len();
                        let _ = active_w;
                        let inserts_per_strip_idx: Vec<usize> =
                            gap_cols_before.iter().map(|v| v.len()).collect();

                        // gaplen[k] indexed by post-restore residue count
                        // (= anchors + restored common-gaps). Value = new-
                        // merge-gap count right after the k-th residue.
                        let mut gaplen = vec![0usize; post_restore_w + 2];
                        {
                            let mut pos = 0usize;
                            let mut s = 0usize;
                            for q in 0..post_merge_width {
                                if new_merge_gap_set.contains(&q) {
                                    gaplen[pos] += 1;
                                } else {
                                    pos += inserts_per_strip_idx.get(s).copied().unwrap_or(0);
                                    pos += 1;
                                    s += 1;
                                }
                            }
                        }

                        // gapmap[k] indexed by post-restore position. Value =
                        // common-gap block length starting at k.
                        let mut gapmap = vec![0usize; post_restore_w + 2];
                        {
                            // Walk post-restore positions in the same order
                            // restore_common_gaps_to_merged_row emits them.
                            let mut p = 0usize;
                            let mut s = 0usize;
                            for q in 0..post_merge_width {
                                if new_merge_gap_set.contains(&q) {
                                    p += 1; // new-merge-gap col emitted as-is
                                } else {
                                    let n_common = inserts_per_strip_idx.get(s).copied().unwrap_or(0);
                                    if n_common > 0 {
                                        gapmap[p] = n_common;
                                    }
                                    p += n_common; // skip the restored '-' chars
                                    p += 1; // the anchor itself
                                    s += 1;
                                }
                            }
                            // Trailing
                            let n_common = inserts_per_strip_idx.get(anchor_positions.len()).copied().unwrap_or(0);
                            if n_common > 0 && p < gapmap.len() {
                                gapmap[p] = n_common;
                            }
                        }

                        apply_c_insertnewgaps(
                            &mut aligned,
                            existing_grp,
                            new_grp,
                            &other_indices,
                            &gaplen,
                            &gapmap,
                            scoring,
                            &gap,
                        );
                    } else {
                        for i in other_indices {
                            let other_pre = aligned[i].clone();
                            if other_pre.len() == pre_width {
                                aligned[i] = build_other_post_restore_row(
                                    &other_pre,
                                    &anchor_positions,
                                    &gap_cols_before,
                                    &new_merge_gap_set,
                                    post_merge_width,
                                );
                            }
                        }
                    }
                }

                // Mark new-side rows as aligned (existing-side was already).
                for &i in new_grp {
                    already_aligned[i] = true;
                }

                let width = aligned[step.left[0]].len();
                let _ = (pre_width, n_gap_cols, stripped_width, post_merge_width, n1);
                step_trace.push(StepTrace {
                    clus1: step.left.len(),
                    clus2: step.right.len(),
                    width,
                    score: last_score,
                });
            }
            MergeOrAlign::Wide => {
                // Both sides have new sequences. C does no per-step strip
                // for case 'w' (`disttbfast.c:2745-2756` only strips for
                // cases '1' and '2'). Just run the merge.
                last_score = merge_step_cached(
                    &step.left,
                    &step.right,
                    &mut aligned,
                    &weights,
                    scoring,
                    &gap,
                    use_fft,
                    &mut profile_cache,
                    None,
                    false,
                    false,
                    false, // c_compat off in --add path
                    true,  // --add: pass 0 only, cache valid
                );

                for &i in step.left.iter().chain(step.right.iter()) {
                    already_aligned[i] = true;
                }

                let width = aligned[step.left[0]].len().max(aligned[step.right[0]].len());
                step_trace.push(StepTrace {
                    clus1: step.left.len(),
                    clus2: step.right.len(),
                    width,
                    score: last_score,
                });
            }
        }
    }

    let max_width = aligned.iter().map(|s| s.len()).max().unwrap_or(0);
    for seq in &mut aligned {
        seq.resize(max_width, b'-');
    }

    MultipleAlignment {
        sequences: aligned, names: names.to_vec(), score: last_score, step_trace,
        guide_tree: None, first_pass_sequences: None, distance_matrix: None,
    }
}

/// Walk pre-strip and post-merge representatives to identify "new merge gap"
/// post-merge positions. A new merge gap is a post-merge column that doesn't
/// correspond to any pre-strip char (i.e., the DP's "insert" op put a gap in
/// existing_grp at this column).
///
/// Lockstep: walk post; for each post char, if it equals the next pre char,
/// advance both. Otherwise, mark it as a new merge gap. This works because
/// the merge preserves residue order (only inserts gap chars), so pre and
/// post agree on residues with `post_merge_W - L_strip` extra gaps inserted.
fn compute_new_merge_gap_set(
    pre_strip: &[u8],
    post_merge: &[u8],
) -> std::collections::HashSet<usize> {
    let mut gap_set = std::collections::HashSet::new();
    let mut p = 0usize;
    for (q, &c) in post_merge.iter().enumerate() {
        if p < pre_strip.len() && pre_strip[p] == c {
            p += 1;
        } else {
            gap_set.insert(q);
        }
    }
    gap_set
}

/// For a merged-group row at post_merge_W chars, expand to post-restore
/// width by inserting `inserts_per_strip_idx[s]` gap chars right BEFORE
/// the s-th type A (anchor) position, plus trailing
/// `inserts_per_strip_idx[L_strip]` gap chars at the end. Mirrors C's
/// `restorecommongaps` (`addfunctions.c:1453`).
fn restore_common_gaps_to_merged_row(
    row: &[u8],
    new_merge_gap_set: &std::collections::HashSet<usize>,
    inserts_per_strip_idx: &[usize],
) -> Vec<u8> {
    let total_inserts: usize = inserts_per_strip_idx.iter().sum();
    let mut out = Vec::with_capacity(row.len() + total_inserts);
    let mut s = 0usize;
    for q in 0..row.len() {
        if !new_merge_gap_set.contains(&q) {
            for _ in 0..inserts_per_strip_idx[s] {
                out.push(b'-');
            }
            out.push(row[q]);
            s += 1;
        } else {
            out.push(row[q]);
        }
    }
    let l_strip = inserts_per_strip_idx.len() - 1;
    for _ in 0..inserts_per_strip_idx[l_strip] {
        out.push(b'-');
    }
    out
}

/// commongappick — strip cols where ALL rows are '-' or '.'. Mirrors
/// C `mltaln9.c::commongappick`. In-place.
fn commongappick_inplace(mseq: &mut Vec<Vec<u8>>) {
    if mseq.is_empty() || mseq[0].is_empty() { return; }
    let n = mseq.len();
    let len = mseq[0].len();
    let mut keep = vec![true; len];
    for j in 0..len {
        let all_gap = (0..n).all(|i| {
            let c = mseq[i].get(j).copied().unwrap_or(b'-');
            c == b'-'
        });
        if all_gap { keep[j] = false; }
    }
    for row in mseq.iter_mut() {
        let new_row: Vec<u8> = row.iter().enumerate()
            .filter(|(j, _)| keep[*j])
            .map(|(_, &c)| c)
            .collect();
        *row = new_row;
    }
}

/// Port of C `addfunctions.c::profilealignment` (static at line 127).
/// Aligns OTHER's content (`mseq0`) with group2's content (`mseq2`)
/// at a single gap region, then marks group1's `mseq1` with '-' or
/// '=' based on the resulting alignment. Mutates all three in place.
///
/// Used by `apply_c_insertnewgaps` at each new-merge-gap region
/// adjacent to a common-gap restoration (gapshift2 > 0). The key
/// effect: when commongappick strips all-gap cols from `mseq0` /
/// `mseq2`, the resulting alignment is COMPRESSED (newlen < input
/// width) — this is what closes R-6's `--add` adversarial gap.
fn rs_profilealignment(
    mseq0: &mut Vec<Vec<u8>>,
    mseq1: &mut Vec<Vec<u8>>,
    mseq2: &mut Vec<Vec<u8>>,
    scoring: &ScoringContext,
    gap: &GapModel,
) {
    // C: if (aln0[0][1] == 0 && aln2[0][1] == 0) return; — single-char
    // case with --allowshift off. Skip — non-trivial case is the
    // adversarial input.

    commongappick_inplace(mseq0);
    commongappick_inplace(mseq2);

    // C edge case (line 154): if mseq2 first row is empty (no
    // residues), fill all mseq2 with gap chars matching mseq0 length
    // and return. mseq1 untouched.
    let n0 = mseq0.len();
    let n1 = mseq1.len();
    let n2 = mseq2.len();
    if n2 == 0 || mseq2[0].is_empty() {
        let target_len = if n0 > 0 { mseq0[0].len() } else { 0 };
        for row in mseq2.iter_mut() {
            *row = vec![b'-'; target_len];
        }
        return;
    }

    // Build per-row weights as 1/alcount for non-all-gap rows, 0 else.
    let alcount0 = mseq0.iter().filter(|r| r.iter().any(|&c| c != b'-')).count().max(1);
    let alcount2 = mseq2.iter().filter(|r| r.iter().any(|&c| c != b'-')).count().max(1);
    let eff0: Vec<f64> = mseq0.iter().map(|r| {
        if r.iter().any(|&c| c != b'-') { 1.0 / alcount0 as f64 } else { 0.0 }
    }).collect();
    let eff2: Vec<f64> = mseq2.iter().map(|r| {
        if r.iter().any(|&c| c != b'-') { 1.0 / alcount2 as f64 } else { 0.0 }
    }).collect();

    let mseq0_refs: Vec<&[u8]> = mseq0.iter().map(|v| v.as_slice()).collect();
    let mseq2_refs: Vec<&[u8]> = mseq2.iter().map(|v| v.as_slice()).collect();
    let prof0 = Profile::from_aligned(&mseq0_refs, &eff0, &scoring.amino_map, scoring.nalphabets);
    let prof2 = Profile::from_aligned(&mseq2_refs, &eff2, &scoring.amino_map, scoring.nalphabets);

    // C uses outgap=1 (headgp=1, tailgp=1) in the A__align call.
    let aln = profile_align(&prof0, &prof2, &scoring.consweight_matrix, gap, true, true);

    // Apply ops to produce new mseq0/mseq2.
    let mut new_mseq0: Vec<Vec<u8>> = vec![Vec::new(); n0];
    let mut new_mseq2: Vec<Vec<u8>> = vec![Vec::new(); n2];
    let mut cur_i = vec![0usize; n0];
    let mut cur_j = vec![0usize; n2];
    for op in &aln.operations {
        match op {
            AlignOp::Match => {
                for i in 0..n0 {
                    new_mseq0[i].push(mseq0[i].get(cur_i[i]).copied().unwrap_or(b'-'));
                    cur_i[i] += 1;
                }
                for j in 0..n2 {
                    new_mseq2[j].push(mseq2[j].get(cur_j[j]).copied().unwrap_or(b'-'));
                    cur_j[j] += 1;
                }
            }
            AlignOp::Delete => {
                for i in 0..n0 {
                    new_mseq0[i].push(mseq0[i].get(cur_i[i]).copied().unwrap_or(b'-'));
                    cur_i[i] += 1;
                }
                for j in 0..n2 {
                    new_mseq2[j].push(b'-');
                }
            }
            AlignOp::Insert => {
                for i in 0..n0 {
                    new_mseq0[i].push(b'-');
                }
                for j in 0..n2 {
                    new_mseq2[j].push(mseq2[j].get(cur_j[j]).copied().unwrap_or(b'-'));
                    cur_j[j] += 1;
                }
            }
        }
    }
    *mseq0 = new_mseq0;
    *mseq2 = new_mseq2;

    // C lines 217-220: fill aln1 with '-' chars at newlen width.
    let newlen = if n0 > 0 { mseq0[0].len() } else if n2 > 0 { mseq2[0].len() } else { 0 };
    for row in mseq1.iter_mut() {
        *row = vec![b'-'; newlen];
    }

    // C lines 222-242: at each j, if all aln0 are '-' AND all aln1 are
    // '-' → mark all aln1 with '=' at j.
    for j in 0..newlen {
        let all_aln0_gap = mseq0.iter().all(|r| r.get(j).copied().unwrap_or(b'-') == b'-');
        if !all_aln0_gap { continue; }
        let all_aln1_gap = mseq1.iter().all(|r| r.get(j).copied().unwrap_or(b'-') == b'-');
        if all_aln1_gap {
            for row in mseq1.iter_mut() {
                row[j] = b'=';
            }
        }
    }
    let _ = n1;
}

/// Port of C `addfunctions.c::findnewgaps` (line 327). gaplen[k]
/// = number of '=' chars right after the k-th non-'=' char in seq.
/// gaplen size = len(seq) + 1.
pub fn findnewgaps(seq: &[u8]) -> Vec<usize> {
    let mut gaplen = vec![0usize; seq.len() + 1];
    let mut pos = 0;
    for &c in seq {
        if c == b'=' { gaplen[pos] += 1; }
        else { pos += 1; }
    }
    gaplen
}

/// Port of C `addfunctions.c::insertnewgaps` (lines 445-650).
/// Operates on the post-restore state (active rows already have
/// common-gap chars restored; OTHER rows still at pre-merge width).
/// Returns the new aligned state for all rows.
///
/// Key invariant: `aseq[OTHER]` walks pre-merge positions via index
/// `j`; `aseq[active]` walks post-restore positions via `posin12`.
/// `gaplen` is indexed by `j` (pre-merge); `gapmap` is indexed by
/// `posin12` (post-restore).
///
/// **Currently SKIPS profilealignment** — for scenarios where the
/// new-merge-gap is NOT adjacent to a common-gap (gapshift2==0
/// branch always taken), this matches C exactly. Profilealignment
/// will be added for the compression cases.
pub fn apply_c_insertnewgaps(
    aseq: &mut [Vec<u8>],
    existing_grp: &[usize],
    new_grp: &[usize],
    other_indices: &[usize],
    gaplen: &[usize],
    gapmap: &[usize],
    scoring: &ScoringContext,
    gap: &GapModel,
) {
    if other_indices.is_empty() {
        return; // C returns early when ngroup0 == 0
    }

    let rep = other_indices[0];
    let len = aseq[rep].len();
    let len0 = len + 1;

    // Output buffers.
    let mut out: Vec<Vec<u8>> = (0..aseq.len()).map(|_| Vec::with_capacity(len * 2 + 16)).collect();

    let mut posin12 = 0usize;
    let mut j = 0usize;
    while j < len0 {
        if j < gaplen.len() && gaplen[j] > 0 {
            // Collect mseq0/1/2 for this gap region.
            let gapshift = gaplen[j];
            let mut mseq0: Vec<Vec<u8>> = (0..other_indices.len()).map(|_| Vec::new()).collect();
            let mut mseq1: Vec<Vec<u8>> = (0..existing_grp.len()).map(|_| Vec::new()).collect();
            let mut mseq2: Vec<Vec<u8>> = (0..new_grp.len()).map(|_| Vec::new()).collect();

            // First gapshift = new-merge-gap region (gaplen[j] '=' chars in
            // group1 post-restore).
            for row in mseq0.iter_mut() {
                for _ in 0..gapshift { row.push(b'-'); }
            }
            for (k, &i) in existing_grp.iter().enumerate() {
                for kk in 0..gapshift {
                    let c = aseq[i].get(posin12 + kk).copied().unwrap_or(b'-');
                    mseq1[k].push(c);
                }
            }
            for (k, &i) in new_grp.iter().enumerate() {
                for kk in 0..gapshift {
                    let c = aseq[i].get(posin12 + kk).copied().unwrap_or(b'-');
                    mseq2[k].push(c);
                }
            }
            posin12 += gapshift;

            // Second gapshift = gapmap[posin12] (adjacent common-gap region).
            // OTHER takes from pre-merge j..j+gapshift2; active take from
            // posin12..posin12+gapshift2.
            let gapshift2 = gapmap.get(posin12).copied().unwrap_or(0);
            if gapshift2 > 0 {
                for (k, &i) in other_indices.iter().enumerate() {
                    for kk in 0..gapshift2 {
                        let c = aseq[i].get(j + kk).copied().unwrap_or(b'-');
                        mseq0[k].push(c);
                    }
                }
                for (k, &i) in existing_grp.iter().enumerate() {
                    for kk in 0..gapshift2 {
                        let c = aseq[i].get(posin12 + kk).copied().unwrap_or(b'-');
                        mseq1[k].push(c);
                    }
                }
                for (k, &i) in new_grp.iter().enumerate() {
                    for kk in 0..gapshift2 {
                        let c = aseq[i].get(posin12 + kk).copied().unwrap_or(b'-');
                        mseq2[k].push(c);
                    }
                }

                // Run profilealignment — this can compress the mseq buffers.
                rs_profilealignment(&mut mseq0, &mut mseq1, &mut mseq2, scoring, gap);

                j += gapshift2;
                posin12 += gapshift2;
            }

            // Append the (possibly compressed) mseq buffers to out.
            for (k, &i) in other_indices.iter().enumerate() {
                out[i].extend_from_slice(&mseq0[k]);
            }
            for (k, &i) in existing_grp.iter().enumerate() {
                out[i].extend_from_slice(&mseq1[k]);
            }
            for (k, &i) in new_grp.iter().enumerate() {
                out[i].extend_from_slice(&mseq2[k]);
            }
        }

        // Block-copy: 1+ contiguous anchors where gaplen is 0.
        let mut blocklen = 1;
        let mut q = j + 1;
        while q < len0 && q < gaplen.len() && gaplen[q] == 0 {
            blocklen += 1;
            q += 1;
        }

        // C's strncpy0 stops at source NUL. We mirror by breaking when
        // the source index goes past the row's actual length.
        for &i in other_indices {
            for k in 0..blocklen {
                if let Some(&c) = aseq[i].get(j + k) {
                    if c != 0 { out[i].push(c); }
                } else { break; }
            }
        }
        for &i in existing_grp {
            for k in 0..blocklen {
                if let Some(&c) = aseq[i].get(posin12 + k) {
                    if c != 0 { out[i].push(c); }
                } else { break; }
            }
        }
        for &i in new_grp {
            for k in 0..blocklen {
                if let Some(&c) = aseq[i].get(posin12 + k) {
                    if c != 0 { out[i].push(c); }
                } else { break; }
            }
        }

        j += blocklen;
        posin12 += blocklen;
    }

    // Trim trailing zeros from output rows (defensive).
    for row in out.iter_mut() {
        while row.last() == Some(&0) { row.pop(); }
    }

    // Copy back to aseq for affected rows.
    for &i in other_indices.iter().chain(existing_grp).chain(new_grp) {
        aseq[i] = std::mem::take(&mut out[i]);
    }
}

/// `insertnewgaps` with `profilealignment` — partial port of C
/// `addfunctions.c::insertnewgaps` (lines 445-650) including the
/// per-gap-region `profilealignment` call (`addfunctions.c:127`).
///
/// **Status: NOT yet wired in.** This is the structural scaffold
/// for closing R-6's `--add` adversarial-input divergence; the
/// remaining piece is synchronizing the active rows' widths with
/// OTHER's when profilealignment changes the gap-region width.
///
/// At each new-merge-gap region of length `g` in post-merge space,
/// C extracts OTHER's content from the next `g` pre-merge anchor
/// positions and runs a profile alignment against group2's content
/// in the same region. The result reshuffles OTHER's residues into
/// the gap region (instead of leaving them at the anchor positions
/// and padding the gap region with '-' as
/// `build_other_post_restore_row` does).
///
/// For default biological inputs this collapses to a no-op (the
/// flat-padding result matches), but on adversarial inputs where
/// the added sequence's insertions span positions where OTHER has
/// residues, the profile alignment compresses the result by up to
/// `g` columns.
///
/// Returns the new aligned slices for OTHER rows (in `other_indices`
/// order). When integrated, the active rows (existing+new) must
/// also be regenerated with the matching width.
#[allow(dead_code, clippy::too_many_arguments)]
fn insertnewgaps_with_profilealignment(
    aligned: &[Vec<u8>],
    other_indices: &[usize],
    existing_grp: &[usize],
    new_grp: &[usize],
    anchor_positions: &[usize],
    gap_cols_before: &[Vec<usize>],
    new_merge_gap_set: &std::collections::HashSet<usize>,
    post_merge_width: usize,
    scoring: &ScoringContext,
    gap: &GapModel,
) -> Vec<Vec<u8>> {
    // Build mapping: post-merge col q -> stripped_idx (anchor index in
    // pre-merge) right AFTER it. Used to translate gap-region post-merge
    // start positions into pre-merge anchor index ranges.
    let stripped_width = anchor_positions.len();
    let mut anchor_idx_at_post: Vec<usize> = Vec::with_capacity(post_merge_width);
    {
        let mut s = 0usize;
        for q in 0..post_merge_width {
            if !new_merge_gap_set.contains(&q) { s += 1; }
            anchor_idx_at_post.push(s); // anchor count consumed up to and including q
        }
    }

    // Find maximal runs of consecutive new_merge_gap_set columns.
    let mut gap_runs: Vec<(usize, usize)> = Vec::new(); // (q_start, length)
    {
        let mut q = 0;
        while q < post_merge_width {
            if new_merge_gap_set.contains(&q) {
                let start = q;
                while q < post_merge_width && new_merge_gap_set.contains(&q) { q += 1; }
                gap_runs.push((start, q - start));
            } else {
                q += 1;
            }
        }
    }

    // Build OTHER's pre-merge content (one row per other index).
    let other_pre: Vec<Vec<u8>> = other_indices.iter().map(|&i| aligned[i].clone()).collect();
    // post-restore output rows for OTHER (to be filled).
    let mut other_out: Vec<Vec<u8>> = vec![Vec::with_capacity(post_merge_width); other_indices.len()];

    // Walk anchors s = 0..stripped_width and maintain post-merge col q.
    let mut s = 0usize;
    let mut run_idx = 0usize;
    let mut q = 0usize;
    while s < stripped_width {
        // Skip any new-merge-gap run starting at q.
        if run_idx < gap_runs.len() && gap_runs[run_idx].0 == q {
            let (g_start, g_len) = gap_runs[run_idx];
            run_idx += 1;
            // Consume the next g_len anchors as OTHER's source for this
            // gap region (C `insertnewgaps:571`: mseq0 = seq[list0[i]]+j
            // for gapshift chars).
            let consume = g_len.min(stripped_width - s);
            let src_anchors = &anchor_positions[s..s + consume];

            // mseq0: OTHER's chars at those anchor positions.
            let mseq0: Vec<Vec<u8>> = other_pre.iter().map(|row| {
                src_anchors.iter().map(|&k| row.get(k).copied().unwrap_or(b'-')).collect()
            }).collect();

            // mseq2: new-side (group2) post-merge chars in the gap run.
            // group2 was updated by merge_step_cached; aligned[new_grp[k]]
            // currently has post-merge content (already updated by
            // restore_common_gaps_to_merged_row in the caller). But here
            // we receive raw aligned BEFORE restore in the new path, so
            // we use the post-merge index directly.
            let mseq2: Vec<Vec<u8>> = new_grp.iter().map(|&i| {
                aligned[i].iter().skip(g_start).take(g_len).copied().collect()
            }).collect();

            // mseq0 commongappick: for our case (singletons or small)
            // the all-gap check is a no-op since rows here came from
            // anchor positions where seq[0] had a residue (so OTHER
            // could be residue or gap but not all-gap).
            // Build profiles and run profile_align.
            let m_refs: Vec<&[u8]> = mseq0.iter().map(|v| v.as_slice()).collect();
            let n_refs: Vec<&[u8]> = mseq2.iter().map(|v| v.as_slice()).collect();
            let n0 = m_refs.len();
            let _n2 = n_refs.len();
            let alcount0 = mseq0.iter().filter(|r| r.iter().any(|&c| c != b'-')).count().max(1);
            let alcount2 = mseq2.iter().filter(|r| r.iter().any(|&c| c != b'-')).count().max(1);
            let w0: Vec<f64> = mseq0.iter().map(|r| {
                if r.iter().any(|&c| c != b'-') { 1.0 / alcount0 as f64 } else { 0.0 }
            }).collect();
            let w2: Vec<f64> = mseq2.iter().map(|r| {
                if r.iter().any(|&c| c != b'-') { 1.0 / alcount2 as f64 } else { 0.0 }
            }).collect();
            let prof0 = Profile::from_aligned(&m_refs, &w0, &scoring.amino_map, scoring.nalphabets);
            let prof2 = Profile::from_aligned(&n_refs, &w2, &scoring.amino_map, scoring.nalphabets);

            // C uses outgap=1 in the profilealignment A__align call.
            let aln = profile_align(&prof0, &prof2, &scoring.consweight_matrix, gap, true, true);

            // Apply ops to each OTHER row: emit the aligned mseq0[i][k]
            // characters at each Match/Delete op, and '-' at Insert ops.
            let mut cur_i = vec![0usize; n0];
            for op in &aln.operations {
                for i in 0..n0 {
                    match op {
                        AlignOp::Match | AlignOp::Delete => {
                            let c = mseq0[i].get(cur_i[i]).copied().unwrap_or(b'-');
                            other_out[i].push(c);
                            cur_i[i] += 1;
                        }
                        AlignOp::Insert => {
                            other_out[i].push(b'-');
                        }
                    }
                }
            }
            s += consume;
            q = g_start + g_len;
            continue;
        }

        // Not in a gap run: emit OTHER's chars at common-gap positions
        // before this anchor, then OTHER's char at the anchor.
        if s < stripped_width {
            for &k_pre in &gap_cols_before[s] {
                for (i, row) in other_pre.iter().enumerate() {
                    other_out[i].push(row.get(k_pre).copied().unwrap_or(b'-'));
                }
            }
            for (i, row) in other_pre.iter().enumerate() {
                other_out[i].push(row.get(anchor_positions[s]).copied().unwrap_or(b'-'));
            }
            s += 1;
            q += 1;
        }
    }

    // Tail common-gap positions (after the last anchor).
    if let Some(tail) = gap_cols_before.get(stripped_width) {
        for &k_pre in tail {
            for (i, row) in other_pre.iter().enumerate() {
                other_out[i].push(row.get(k_pre).copied().unwrap_or(b'-'));
            }
        }
    }

    // Trailing gap runs (after all anchors consumed).
    while run_idx < gap_runs.len() {
        let (_, g_len) = gap_runs[run_idx];
        for _ in 0..g_len {
            for row in other_out.iter_mut() {
                row.push(b'-');
            }
        }
        run_idx += 1;
    }

    let _ = (existing_grp, anchor_idx_at_post); // referenced for future debug
    other_out
}

/// For an OTHER (already-aligned, not-in-merge) row at pre-merge L_pre,
/// build its post-restore representation. OTHER preserves its char at
/// type A (anchor) and type C (gap_col) positions, and gets gap chars at
/// type B (new merge gap) positions. Mirrors C's `insertnewgaps`
/// (`addfunctions.c:445`) but without the profilealignment refinement.
fn build_other_post_restore_row(
    other_pre: &[u8],
    anchor_positions: &[usize],
    gap_cols_before: &[Vec<usize>],
    new_merge_gap_set: &std::collections::HashSet<usize>,
    post_merge_w: usize,
) -> Vec<u8> {
    let total_size = other_pre.len() + new_merge_gap_set.len();
    let mut out: Vec<u8> = Vec::with_capacity(total_size);
    let mut s = 0usize;
    for q in 0..post_merge_w {
        if new_merge_gap_set.contains(&q) {
            out.push(b'-');
        } else {
            for &k_pre in &gap_cols_before[s] {
                out.push(other_pre.get(k_pre).copied().unwrap_or(b'-'));
            }
            out.push(other_pre.get(anchor_positions[s]).copied().unwrap_or(b'-'));
            s += 1;
        }
    }
    let l_strip = anchor_positions.len();
    for &k_pre in &gap_cols_before[l_strip] {
        out.push(other_pre.get(k_pre).copied().unwrap_or(b'-'));
    }
    out
}

/// Run progressive alignment merges 0..n_steps and return the
/// intermediate `aligned[]` state at that point.
///
/// Used by FFI cross-validation tests that need to reproduce a
/// specific step's input profiles without driving the binary or
/// using env-var-controlled file dumps. Each sequence's length in
/// the returned `Vec<Vec<u8>>` matches whatever cluster width it has
/// at step `n_steps` entry (sequences in different clusters have
/// different widths, mirroring C's progressive merge state).
///
/// Passing `n_steps == topology.steps.len()` runs all merges and
/// returns the final padded alignment.
pub fn progressive_align_partial(
    sequences: &[Vec<u8>],
    topology: &Topology,
    scoring: &ScoringContext,
    use_fft: bool,
    shift_penalty: Option<f64>,
    n_steps: usize,
) -> Vec<Vec<u8>> {
    let nseq = sequences.len();
    if nseq <= 1 {
        return sequences.to_vec();
    }

    let weights = sequence_weights(topology);
    let mut aligned: Vec<Vec<u8>> = sequences.to_vec();

    let mut gap = GapModel::new(scoring.gap.open as f64, scoring.gap.extend as f64);
    if let Some(shift) = shift_penalty {
        gap = gap.with_shift(shift);
    }

    let mut profile_cache: BTreeMap<Vec<usize>, CachedProfile> = BTreeMap::new();

    let limit = n_steps.min(topology.steps.len());
    for step in topology.steps.iter().take(limit) {
        merge_step_cached(
            &step.left, &step.right, &mut aligned, &weights, scoring, &gap, use_fft,
            &mut profile_cache, None, false, false,
            false, // c_compat off in partial replay (test diagnostics)
            true,  // partial replay mirrors pass 0
        );
    }
    aligned
}

/// Progressive alignment with optional local-homology constraints.
///
/// When `constraints` is `Some`, every merge calls `profile_align_imp`
/// with a per-merge impmtx built via `mafft-align::build_imp_matrix` —
/// mirroring what `tbfast` does in C's L-INS-i pipeline (`Falign_localhom`,
/// or the non-FFT `partA__align` per segment). Without this the initial
/// progressive alignment is FFT-NS-i-like and only refinement sees the
/// constraints, leaving L-INS-i/E-INS-i shapes systematically off vs C.
pub fn progressive_align_with_constraints(
    sequences: &[Vec<u8>],
    names: &[String],
    topology: &Topology,
    scoring: &ScoringContext,
    use_fft: bool,
    shift_penalty: Option<f64>,
    constraints: Option<&mafft_types::LocalHomologyTable>,
    penalize_term_gaps: bool,
) -> MultipleAlignment {
    progressive_align_with_weights_override(
        sequences, names, topology, scoring, use_fft, shift_penalty,
        constraints, penalize_term_gaps, None,
    )
}

/// Like `progressive_align_with_constraints` but allows overriding the
/// per-sequence weights. When `weights_override` is `Some(w)`, each
/// `w[i]` is used directly (still normalized within each cluster at
/// merge time). When `None`, the weights come from
/// `sequence_weights(topology)` (the tree-derived
/// `weightFromABranch`-based defaults).
pub fn progressive_align_with_weights_override(
    sequences: &[Vec<u8>],
    names: &[String],
    topology: &Topology,
    scoring: &ScoringContext,
    use_fft: bool,
    shift_penalty: Option<f64>,
    constraints: Option<&mafft_types::LocalHomologyTable>,
    penalize_term_gaps: bool,
    weights_override: Option<&[f64]>,
) -> MultipleAlignment {
    progressive_align_full(
        sequences, names, topology, scoring, use_fft, shift_penalty,
        constraints, penalize_term_gaps, weights_override, 0.0, false, false,
    )
}

/// Like `progressive_align_with_weights_override` but with `unalign_level`
/// (C's `specificityconsideration`). When `unalign_level > 0`, builds a
/// per-step dynamic substitution matrix that scales by `(distfromtip -
/// unalign_level) * 600` (clamped at 0). Mirrors `disttbfast.c:2304-2307` +
/// `mltaln9.c::makedynamicmtx`. `--allowshift` sets this to 0.8.
pub fn progressive_align_full(
    sequences: &[Vec<u8>],
    names: &[String],
    topology: &Topology,
    scoring: &ScoringContext,
    use_fft: bool,
    shift_penalty: Option<f64>,
    constraints: Option<&mafft_types::LocalHomologyTable>,
    penalize_term_gaps: bool,
    weights_override: Option<&[f64]>,
    unalign_level: f64,
    legacy_gap_cost: bool,
    memsave_dp: bool,
) -> MultipleAlignment {
    progressive_align_full_c_compat(
        sequences, names, topology, scoring, use_fft, shift_penalty,
        constraints, penalize_term_gaps, weights_override, unalign_level,
        legacy_gap_cost, memsave_dp, false,
    )
}

/// Like `progressive_align_full` but with an explicit `c_compat` flag
/// that enables C MAFFT's static-TLS cpmx memoization (see
/// `mafft_align::profile::CPMX_MEMO`). Default callers should use
/// `progressive_align_full` which passes `c_compat=false`.
pub fn progressive_align_full_c_compat(
    sequences: &[Vec<u8>],
    names: &[String],
    topology: &Topology,
    scoring: &ScoringContext,
    use_fft: bool,
    shift_penalty: Option<f64>,
    constraints: Option<&mafft_types::LocalHomologyTable>,
    penalize_term_gaps: bool,
    weights_override: Option<&[f64]>,
    unalign_level: f64,
    legacy_gap_cost: bool,
    memsave_dp: bool,
    c_compat: bool,
) -> MultipleAlignment {
    progressive_align_full_c_compat_ex(
        sequences, names, topology, scoring, use_fft, shift_penalty,
        constraints, penalize_term_gaps, weights_override, unalign_level,
        legacy_gap_cost, memsave_dp, c_compat, true,
    )
}

/// Like `progressive_align_full_c_compat` but with an explicit `use_cache`
/// flag. When `use_cache` is false, every merge rebuilds its child profiles
/// fresh from the current `aligned[]` state via `cpmx_calc_new`, mirroring
/// C MAFFT's `dooneiteration` behavior (`disttbfast.c:2288-2289` —
/// `cpmxchild0/1 = NULL`). When true (default), child profiles are blended
/// from cached parent profiles via `blend_profiles_exact`, mirroring C's
/// `createcpmxresult` in `treebase` (pass 0). The blend matches C's
/// `createcpmxresult` exactly (including its "tsukawanai" comment that
/// excludes the eff*1.0 gap contribution at gap-insertion positions —
/// see `Salignmm.c:622-626`); using it in pass-1+ would diverge from C,
/// which forces fresh `cpmx_calc_new` there.
pub fn progressive_align_full_c_compat_ex(
    sequences: &[Vec<u8>],
    names: &[String],
    topology: &Topology,
    scoring: &ScoringContext,
    use_fft: bool,
    shift_penalty: Option<f64>,
    constraints: Option<&mafft_types::LocalHomologyTable>,
    penalize_term_gaps: bool,
    weights_override: Option<&[f64]>,
    unalign_level: f64,
    legacy_gap_cost: bool,
    memsave_dp: bool,
    c_compat: bool,
    use_cache: bool,
) -> MultipleAlignment {
    let nseq = sequences.len();
    if nseq == 0 {
        return MultipleAlignment {
            sequences: Vec::new(), names: Vec::new(), score: 0.0, step_trace: Vec::new(), guide_tree: None, first_pass_sequences: None, distance_matrix: None,
        };
    }
    if nseq == 1 {
        return MultipleAlignment {
            sequences: sequences.to_vec(), names: names.to_vec(), score: 0.0, step_trace: Vec::new(), guide_tree: None, first_pass_sequences: None, distance_matrix: None,
        };
    }

    let weights = match weights_override {
        Some(w) => w.to_vec(),
        None => sequence_weights(topology),
    };
    let mut aligned: Vec<Vec<u8>> = sequences.to_vec();

    let mut last_score = 0.0;
    // C MAFFT only passes `-g $gexp` (the `--exp` extension penalty)
    // to `disttbfast` (the FFT-NS-2 / FFT-NS-i progressive binary).
    // The constrained progressive path goes through `tbfast` instead,
    // which does NOT get `-g` (`scripts/mafft:2525-2550`). So when
    // constraints are present (L/G/E-INS-i path), we must zero out
    // the extend penalty regardless of what the user set `--exp`
    // to — matching C's effective behaviour. Refinement enforces
    // the same constraint in `refinement.rs::iterative_refine`.
    let progressive_extend = if constraints.is_some() {
        0.0
    } else {
        scoring.gap.extend as f64
    };
    let mut gap = GapModel::new(scoring.gap.open as f64, progressive_extend)
        .with_legacy_gap_cost(legacy_gap_cost);
    if let Some(shift) = shift_penalty {
        gap = gap.with_shift(shift);
    }

    // Profile cache: maps a set of sequence indices (sorted) to its cached profile.
    // After each merge, the merged profile is stored so the next merge can reuse it.
    let mut profile_cache: BTreeMap<Vec<usize>, CachedProfile> = BTreeMap::new();

    // `--c-compat`: reset per-thread cpmx memo at start of this pass
    // (mirrors C `Salignmm.c:1365-1366` which sets previousfirstlen=-1,
    // previousicyc=-1 on buffer resize). For multi-pass progressive
    // (retree>1), each pass starts with a clean memo so cross-pass
    // state doesn't leak.
    if c_compat {
        mafft_align::reset_cpmx_memo();
    }

    // Per-step dynamic-matrix offset. `--allowshift`/`--unalignlevel`
    // triggers `unalign_level > 0`. C builds a fresh `dynamicmtx` per
    // step from the tree node height (`disttbfast.c:2304-2307`).
    let distfromtip: Vec<f64> = if unalign_level > 0.0 {
        compute_distfromtip(topology)
    } else {
        Vec::new()
    };
    // Per-step scoring contexts (only when unalign_level > 0). Each
    // entry differs from `scoring` only in `consweight_matrix` (the
    // f64 version of the substitution matrix used by all DP routines).
    let gap_idx = scoring.amino_map[b'-' as usize] as usize;
    let dyn_scoring: Vec<ScoringContext> = if unalign_level > 0.0 {
        distfromtip
            .iter()
            .map(|&dft| {
                let mut s = scoring.clone();
                s.consweight_matrix =
                    make_dynamic_matrix(&scoring.consweight_matrix, dft, unalign_level, gap_idx);
                s
            })
            .collect()
    } else {
        Vec::new()
    };

    let mut step_trace: Vec<StepTrace> = Vec::with_capacity(topology.steps.len());
    for (step_idx, step) in topology.steps.iter().enumerate() {
        let step_scoring: &ScoringContext = if unalign_level > 0.0 {
            &dyn_scoring[step_idx]
        } else {
            scoring
        };
        last_score = merge_step_cached(
            &step.left, &step.right, &mut aligned, &weights, step_scoring, &gap, use_fft,
            &mut profile_cache, constraints, penalize_term_gaps, memsave_dp, c_compat,
            use_cache,
        );

        let width = aligned[step.left[0]].len().max(aligned[step.right[0]].len());
        step_trace.push(StepTrace {
            clus1: step.left.len(),
            clus2: step.right.len(),
            width,
            score: last_score,
        });
        if std::env::var("MAFFT_DEBUG_STEPS").is_ok() {
            eprintln!("RDBG {} {} {} {} {:.4}",
                step_idx, step.left.len(), step.right.len(), width, last_score);
        }
        if let Ok(f) = std::env::var("RS_PROGRESSIVE_TRACE") {
            use std::io::Write;
            if let Ok(mut fp) = std::fs::OpenOptions::new().create(true).append(true).open(&f) {
                let m1 = step.left[0];
                let m2 = step.right[0];
                let mut h: u64 = 5381;
                for &i in &step.left {
                    for &c in &aligned[i] { h = h.wrapping_mul(33).wrapping_add(c as u64); }
                }
                for &i in &step.right {
                    for &c in &aligned[i] { h = h.wrapping_mul(33).wrapping_add(c as u64); }
                }
                let _ = writeln!(fp, "step={} m1={} m2={} clus1={} clus2={} width={} pscore={:.6} hash={:x}",
                    step_idx, m1, m2, step.left.len(), step.right.len(), width, last_score, h);
            }
        }
        if std::env::var("RDBG_PT_STEPS").is_ok() {
            eprintln!("RDBG_PT step={} clus1={} clus2={} width={} mem1={:?} mem2={:?}",
                step_idx, step.left.len(), step.right.len(), width, step.left, step.right);
        }
        // BB30013 cpmxhist diagnostic: dump the cached profile for this
        // step's output cluster after the merge writes to cache. Matches
        // C's `disttbfast.c::treebase` cpmxhist dump at the same point.
        if let Ok(prefix) = std::env::var("MAFFT_DUMP_CPMX_PREFIX") {
            let mut key = step.left.clone();
            key.extend_from_slice(&step.right);
            key.sort_unstable();
            if let Some(cached) = profile_cache.get(&key) {
                let fname = format!("{}_step_{}.txt", prefix, step_idx);
                if let Ok(mut f) = std::fs::File::create(&fname) {
                    use std::io::Write;
                    let prof = &cached.profile;
                    let cw = prof.length;
                    let na = prof.nalphabets;
                    writeln!(f, "step={} width={} nalphabets={} clus1={} clus2={} score={:.6}",
                        step_idx, cw, na, step.left.len(), step.right.len(), last_score).unwrap();
                    for k in 0..na {
                        write!(f, "F[{}]:", k).unwrap();
                        for j in 0..cw {
                            write!(f, " {:.18e}", prof.freqs[j][k]).unwrap();
                        }
                        writeln!(f).unwrap();
                    }
                    // C's `gapfreq*pt` stores `nongap_freq` (= 1.0 - gap_freq);
                    // see `Salignmm.c:1495,1519` for the post-gapcountf flip.
                    // Rust caches `nongap_freq` of length `cw` and sets
                    // `nongap_freq[cw] = 1.0` implicitly in the DP. To match
                    // C's cpmxhist[nalphabets] which has length cw+1, we
                    // emit cw nongap_freq values + the implied 1.0 terminator.
                    write!(f, "G:").unwrap();
                    for j in 0..cw {
                        write!(f, " {:.18e}", prof.nongap_freq[j]).unwrap();
                    }
                    write!(f, " {:.18e}", 1.0).unwrap();
                    writeln!(f).unwrap();
                    write!(f, "O:").unwrap();
                    for j in 0..cw {
                        write!(f, " {:.18e}", prof.ogcp[j]).unwrap();
                    }
                    writeln!(f).unwrap();
                    write!(f, "N:").unwrap();
                    for j in 0..cw {
                        write!(f, " {:.18e}", prof.fgcp[j]).unwrap();
                    }
                    writeln!(f).unwrap();
                }
            }
        }
    }

    let max_width = aligned.iter().map(|s| s.len()).max().unwrap_or(0);
    for seq in &mut aligned {
        seq.resize(max_width, b'-');
    }

    MultipleAlignment {
        sequences: aligned, names: names.to_vec(), score: last_score, step_trace,
        guide_tree: None, first_pass_sequences: None, distance_matrix: None,
    }
}

/// Merge two pre-formed groups within an existing alignment using the
/// progressive-style Falign (kobetsubunkatsu=0) path — same per-step
/// merge that `progressive_align_full_c_compat_ex` invokes.
///
/// This is what C's `dooneiteration` (`disttbfast.c:2390-2452`) uses
/// to realign a singleton-vs-rest split. Mirrors that with
/// `constraints = None`, no profile cache, no cpmx memo
/// (`c_compat = false`, `use_cache = false`, matching
/// `disttbfast.c:2288-2289`'s `cpmxchild0/1 = NULL` reset on every
/// `dooneiteration` step).
///
/// Returns the score; modifies `aligned` in place — only indices
/// in `group1 ∪ group2` are rewritten.
pub fn merge_two_groups_progressive(
    group1: &[usize],
    group2: &[usize],
    aligned: &mut Vec<Vec<u8>>,
    weights: &[f64],
    scoring: &ScoringContext,
    gap: &GapModel,
    use_fft: bool,
    penalize_term_gaps: bool,
) -> f64 {
    let mut empty_cache: BTreeMap<Vec<usize>, CachedProfile> = BTreeMap::new();
    merge_step_cached(
        group1, group2, aligned, weights, scoring, gap, use_fft,
        &mut empty_cache,
        None,                  // no constraints
        penalize_term_gaps,
        false,                 // memsave_dp off
        false,                 // c_compat off — dooneiteration always rebuilds cpmx
        false,                 // use_cache off — same reason
    )
}

fn merge_step_cached(
    group1: &[usize],
    group2: &[usize],
    aligned: &mut Vec<Vec<u8>>,
    weights: &[f64],
    scoring: &ScoringContext,
    gap: &GapModel,
    use_fft: bool,
    cache: &mut BTreeMap<Vec<usize>, CachedProfile>,
    constraints: Option<&mafft_types::LocalHomologyTable>,
    penalize_term_gaps: bool,
    memsave_dp: bool,
    c_compat: bool,
    use_cache: bool,
) -> f64 {
    let width1 = aligned[group1[0]].len();
    let width2 = aligned[group2[0]].len();

    // RS_DP_DUMP: when set, append one entry per merge call to the file at
    // its value. Format (tab-delimited per line):
    //   step_idx<TAB>group1_indices<TAB>group2_indices<TAB>weights1<TAB>
    //   weights2<TAB>penalty<TAB>penalty_ex<TAB>headgp<TAB>tailgp<TAB>
    //   group1_seqs (semicolon-sep)<TAB>group2_seqs<TAB>... blank for output;
    //   a second line per call is written AFTER profile_align with the output
    //   alignment seqs. Used by R-1-residual investigation.
    let dump_inputs = std::env::var_os("RS_DP_DUMP").is_some();
    let dp_dump_step = if dump_inputs {
        let s1: Vec<String> = group1.iter().map(|&i| String::from_utf8_lossy(&aligned[i]).into_owned()).collect();
        let s2: Vec<String> = group2.iter().map(|&i| String::from_utf8_lossy(&aligned[i]).into_owned()).collect();
        let w1: Vec<f64> = group1.iter().map(|&i| weights[i]).collect();
        let w2: Vec<f64> = group2.iter().map(|&i| weights[i]).collect();
        Some((s1, s2, w1, w2))
    } else { None };

    // Look up cached profiles or build from sequences
    let key1 = sorted_key(group1);
    let key2 = sorted_key(group2);

    // C-compat path: when enabled, build prof1 via `from_aligned_with_memo`
    // so the thread-local CPMX_MEMO incrementally updates when conditions
    // match C's `reuseprofiles` (Salignmm.c:1446-1450). For C, only the
    // FIRST cluster (cluster1) participates in the memo — cluster2 is
    // always rebuilt from scratch (`cpmx_calc_new(seq2, ...)` at
    // Salignmm.c:1555). Mirror that asymmetry here.
    // C MAFFT only uses the cpmxhist cache in pass 0 (`treebase`); pass 1+
    // (`dooneiteration`) sets `cpmxchild0/1 = NULL` and falls through to
    // `cpmx_calc_new` (Salignmm.c:1473-1505 fallback path,
    // disttbfast.c:2288-2289). The cached blend (`createcpmxresult`) omits
    // the eff*1.0 gap-insertion contribution to `cpmx[24][j]` ("tsukawanai"
    // comment at Salignmm.c:624), so reusing it across passes diverges from
    // a fresh build — this surfaces as the BB20018 / BB40046 step-50
    // pass-1 divergences. `use_cache=false` here forces fresh
    // `from_aligned` rebuilds in refinement passes to match C.
    let try_cache = use_cache;
    let (prof1, eff1) = if try_cache && cache.get(&key1).is_some() {
        let cached = cache.get(&key1).unwrap();
        (cached.profile.clone(), cached.eff)
    } else if c_compat {
        build_profile_with_memo(group1, aligned, weights, scoring)
    } else {
        let (prof, eff) = build_profile_from_seqs(group1, aligned, weights, scoring);
        (prof, eff)
    };

    let (prof2, eff2) = if try_cache && cache.get(&key2).is_some() {
        let cached = cache.get(&key2).unwrap();
        (cached.profile.clone(), cached.eff)
    } else {
        let (prof, eff) = build_profile_from_seqs(group2, aligned, weights, scoring);
        (prof, eff)
    };


    // C uses Falign (FFT-accelerated) for ALL steps when ffttry is true
    // (nlen > clus, which is always true). G__align11 is only used when
    // FFT is disabled (use_fft=false) and both groups are single sequences.
    // When alg='A', the non-FFT fallback is A__align (= profile_align).
    //
    // C's disttbfast passes `outgap, outgap` for headgp/tailgp in G__align11
    // and A__align. With the -O flag (always set by mafft script), outgap=0,
    // which means no penalty is applied to terminal gaps (TERMGAPFAC=0).
    let aln = if !use_fft && group1.len() == 1 && group2.len() == 1
        && constraints.is_none()
    {
        // G__align11 path: flat gap penalty, character-level scoring.
        // Only used when FFT is disabled and no constraints. With constraints
        // (L-INS-i / E-INS-i / G-INS-i), single-vs-single merges still need
        // the impmtx contribution per cell — fall through to the constrained
        // profile DP below.
        //
        // `head_gap` / `tail_gap` must mirror C's `outgap` (= `penalize_term_gaps`).
        // For `--parttree --nofft`, the script omits `-O` so `outgap=1`
        // (`scripts/mafft:2655`, `splittbfast.c:560`), meaning every per-pair
        // merge — including 1-vs-1 — must penalize terminal gaps. Hardcoding
        // `false` here caused the `--parttree --nofft` 944-line divergence vs C
        // (every 1-vs-1 merge took the wrong head/tail gap path).
        // Pass `scoring.gap.extend` so the 1-vs-1 NW DP applies
        // `fpenalty_ex` per cell (port of C `Galign11.c:1362,1383`).
        // Without it, `--nofft --exp > 0` diverged at tied-trace
        // gap positions (R-1b closure).
        pairwise_align11_ex(
            &aligned[group1[0]], &aligned[group2[0]],
            &scoring.consweight_matrix, &scoring.amino_map,
            scoring.gap.open as f64, scoring.gap.extend as f64,
            penalize_term_gaps, penalize_term_gaps,
        )
    } else if use_fft {
        // C uses Falign for ALL steps when use_fft=true (ffttry = nlen > clus,
        // always true). No minimum profile length check.
        //
        // For protein scoring matrices C uses 2-channel polarity+volume FFT
        // via `seq_vec_2` (`Falign.c:342-348`). Build per-internal-index
        // polarity/volume vectors so `find_fft_anchors` can mirror that.
        let property_channels = if scoring.seq_type.is_nucleotide() {
            None
        } else {
            let nscored = scoring.nscoredalphabets;
            let mut polarity_by_idx = vec![0.0f64; nscored];
            let mut volume_by_idx = vec![0.0f64; nscored];
            for ch in 0u16..256 {
                let idx = scoring.amino_map[ch as usize] as usize;
                if idx < nscored {
                    polarity_by_idx[idx] = scoring.polarity[ch as usize];
                    volume_by_idx[idx] = scoring.volume[ch as usize];
                }
            }
            Some((polarity_by_idx, volume_by_idx))
        };
        let fft_params = FftAlignParams {
            num_candidates: 20,
            segment_params: if scoring.seq_type.is_nucleotide() {
                mafft_fft::SegmentParams::dna()
            } else {
                mafft_fft::SegmentParams::protein()
            },
            gap: gap.clone(),
            // C `Falign.c:686-687` sets the per-segment `headgp/tailgp`
            // to the global `outgap` for the first/last segment. So
            // when outgap=1 (term gaps penalized — G-INS-i, --parttree),
            // we set head_gap/tail_gap=true. Threaded via
            // `penalize_term_gaps`.
            head_gap: penalize_term_gaps,
            tail_gap: penalize_term_gaps,
            num_channels: scoring.nscoredalphabets,
            property_channels,
        };
        fft_profile_align(&prof1, &prof2, &scoring.consweight_matrix, &fft_params)
    } else if let Some(table) = constraints {
        // Constraint-aware progressive merge (L-INS-i / E-INS-i tbfast path).
        // Build per-cell impmtx from the localhom table over the group split,
        // then call the importance-aware DP. Mirrors C's `partA__align` /
        // `Falign_localhom` per-segment DP with `imp_match_out_vead` adding
        // the importance bonus row-by-row.
        let g1_seq_refs: Vec<&[u8]> = group1.iter().map(|&i| aligned[i].as_slice()).collect();
        let g2_seq_refs: Vec<&[u8]> = group2.iter().map(|&i| aligned[i].as_slice()).collect();
        // Group-local sum-1 normalized weights (matches C's
        // `fastconjuction_noname` `peff[m] /= total`, tddis.c:552-556).
        const MINIMUM_WEIGHT: f64 = 0.00001;
        let w1: Vec<f64> = group1.iter().map(|&i| weights[i].max(MINIMUM_WEIGHT)).collect();
        let w2: Vec<f64> = group2.iter().map(|&i| weights[i].max(MINIMUM_WEIGHT)).collect();
        let s1: f64 = w1.iter().sum();
        let s2: f64 = w2.iter().sum();
        let w1n: Vec<f64> = if s1 > 0.0 { w1.iter().map(|w| w / s1).collect() } else { vec![1.0; group1.len()] };
        let w2n: Vec<f64> = if s2 > 0.0 { w2.iter().map(|w| w / s2).collect() } else { vec![1.0; group2.len()] };
        let imp = mafft_align::build_imp_matrix(
            table,
            group1, group2,
            &g1_seq_refs, &g2_seq_refs,
            &w1n, &w2n,
            prof1.length, prof2.length,
            mafft_align::FASTATHRESHOLD_DEFAULT,
        );
        if std::env::var_os("RUST_IMP_DUMP").is_some() {
            let s00 = imp.first().and_then(|r| r.first()).copied().unwrap_or(0.0);
            let s100 = if imp.len()>100 && imp[100].len()>100 { imp[100][100] } else { 0.0 };
            let s300 = if imp.len()>300 && imp[300].len()>300 { imp[300][300] } else { 0.0 };
            eprintln!("[RUST_IMP] g1={:?} g2={:?} lgth1={} lgth2={} eff1={:?} eff2={:?} imp[0,0]={:.4} imp[100,100]={:.4} imp[300,300]={:.4}",
                group1, group2, prof1.length, prof2.length, w1n, w2n, s00, s100, s300);
            for &gi in group1 {
                for &gj in group2 {
                    let regs = table.get(gi, gj);
                    for (idx, r) in regs.iter().enumerate().take(3) {
                        eprintln!("[RUST_IMP] lh[{},{}] e{}: opt={:.6} imp={:.6} overlapaa={} s1={} e1={} s2={} e2={}",
                            gi, gj, idx, r.opt, r.importance, r.overlapaa, r.start1, r.end1, r.start2, r.end2);
                    }
                }
            }
        }
        mafft_align::profile_align_imp(
            &prof1, &prof2, &scoring.consweight_matrix, gap,
            penalize_term_gaps, penalize_term_gaps, Some(&imp),
        )
    } else if memsave_dp {
        // `--memsave`: route through the Hirschberg DP. Mirrors C
        // MAFFT's `MSalignmm` (`tbfast.c:1159-1161` under `alg='M'`).
        // For inputs that fit in memory, the alignment is the same
        // as `profile_align` — only memory layout differs.
        mafft_align::msalignmm(
            &prof1, &prof2, &scoring.consweight_matrix, gap,
            penalize_term_gaps, penalize_term_gaps,
        )
    } else {
        // Non-FFT, no-constraints fallback (`--nofft` path or single-vs-
        // single without constraints). C's `outgap` flows through here
        // via `penalize_term_gaps`: false → outgap=0 (term-gap free,
        // FFT-NS-2/L-INS-i/E-INS-i defaults), true → outgap=1
        // (G-INS-i and `--parttree`).
        profile_align(
            &prof1, &prof2, &scoring.consweight_matrix, gap,
            penalize_term_gaps, penalize_term_gaps,
        )
    };

    // Build gaptables for profile caching (matching C's gaptable1/gaptable2)
    let new_width = aln.operations.len();
    let mut gaptable1 = Vec::with_capacity(new_width); // 'o' = content, '-' = gap
    let mut gaptable2 = Vec::with_capacity(new_width);
    for op in &aln.operations {
        match op {
            AlignOp::Match => { gaptable1.push(b'o'); gaptable2.push(b'o'); }
            AlignOp::Delete => { gaptable1.push(b'o'); gaptable2.push(b'-'); }
            AlignOp::Insert => { gaptable1.push(b'-'); gaptable2.push(b'o'); }
        }
    }

    // Cache the merged profile (C's createcpmxresult + creategapfreqresult +
    // createogresult + createfgresult). Only cache for groups > 20 sequences
    // (matching C's condition at MSalignmm.c line 2431).
    let total_eff = eff1 + eff2;
    let combined_seqs = group1.len() + group2.len();
    if total_eff > 0.0 && combined_seqs > 20 {
        let norm_eff1 = eff1 / total_eff;
        let norm_eff2 = eff2 / total_eff;
        let merged_prof = blend_profiles_exact(
            &prof1, &prof2,
            norm_eff1, norm_eff2,
            &gaptable1, &gaptable2,
            scoring.nalphabets,
        );
        let mut merged_key = group1.to_vec();
        merged_key.extend_from_slice(group2);
        merged_key.sort();
        cache.insert(merged_key, CachedProfile {
            profile: merged_prof,
            eff: total_eff,
        });
    }

    // Remove child caches (they won't be needed again)
    cache.remove(&key1);
    cache.remove(&key2);

    // Build new sequences for group1 and group2 ONLY
    let mut cursor1 = 0usize;
    let mut cursor2 = 0usize;
    let mut new_seqs_g1: Vec<Vec<u8>> = vec![Vec::with_capacity(new_width); group1.len()];
    let mut new_seqs_g2: Vec<Vec<u8>> = vec![Vec::with_capacity(new_width); group2.len()];

    for op in &aln.operations {
        match op {
            AlignOp::Match => {
                for (gi, &idx) in group1.iter().enumerate() {
                    new_seqs_g1[gi].push(if cursor1 < width1 { aligned[idx][cursor1] } else { b'-' });
                }
                for (gi, &idx) in group2.iter().enumerate() {
                    new_seqs_g2[gi].push(if cursor2 < width2 { aligned[idx][cursor2] } else { b'-' });
                }
                cursor1 += 1;
                cursor2 += 1;
            }
            AlignOp::Delete => {
                for (gi, &idx) in group1.iter().enumerate() {
                    new_seqs_g1[gi].push(if cursor1 < width1 { aligned[idx][cursor1] } else { b'-' });
                }
                for gi in 0..group2.len() { new_seqs_g2[gi].push(b'-'); }
                cursor1 += 1;
            }
            AlignOp::Insert => {
                for gi in 0..group1.len() { new_seqs_g1[gi].push(b'-'); }
                for (gi, &idx) in group2.iter().enumerate() {
                    new_seqs_g2[gi].push(if cursor2 < width2 { aligned[idx][cursor2] } else { b'-' });
                }
                cursor2 += 1;
            }
        }
    }

    for (gi, &idx) in group1.iter().enumerate() { aligned[idx] = new_seqs_g1[gi].clone(); }
    for (gi, &idx) in group2.iter().enumerate() { aligned[idx] = new_seqs_g2[gi].clone(); }

    // RS_DP_DUMP: write per-step inputs + outputs to the file at the env
    // var's value. Format (one tab-separated entry per line):
    //   group1<TAB>group2<TAB>w1<TAB>w2<TAB>penalty<TAB>penalty_ex<TAB>
    //   headgp<TAB>tailgp<TAB>use_fft<TAB>has_constraint<TAB>out1<TAB>out2
    // Where group{1,2} are semicolon-separated input row sequences,
    // out{1,2} are semicolon-separated post-merge row sequences.
    if let Some((s1, s2, w1, w2)) = dp_dump_step {
        if let Ok(path) = std::env::var("RS_DP_DUMP") {
            use std::io::Write;
            if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
                let out1: Vec<String> = new_seqs_g1.iter().map(|v| String::from_utf8_lossy(v).into_owned()).collect();
                let out2: Vec<String> = new_seqs_g2.iter().map(|v| String::from_utf8_lossy(v).into_owned()).collect();
                let _ = writeln!(f,
                    "g1={}\tg2={}\tw1={}\tw2={}\tpen={}\tpen_ex={}\thgp={}\ttgp={}\tfft={}\tcon={}\tout1={}\tout2={}",
                    s1.join(";"), s2.join(";"),
                    w1.iter().map(|x| format!("{:.10}", x)).collect::<Vec<_>>().join(","),
                    w2.iter().map(|x| format!("{:.10}", x)).collect::<Vec<_>>().join(","),
                    gap.open as i32, gap.extend as i32,
                    penalize_term_gaps as u8, penalize_term_gaps as u8,
                    use_fft as u8,
                    constraints.is_some() as u8,
                    out1.join(";"), out2.join(";"),
                );
            }
        }
    }

    aln.score
}

fn sorted_key(group: &[usize]) -> Vec<usize> {
    let mut k = group.to_vec();
    k.sort();
    k
}

fn build_profile_from_seqs(
    group: &[usize],
    aligned: &[Vec<u8>],
    weights: &[f64],
    scoring: &ScoringContext,
) -> (Profile, f64) {
    let seqs: Vec<&[u8]> = group.iter().map(|&i| aligned[i].as_slice()).collect();
    // C normalizes weights to sum to 1.0 within each group for cpmx_calc_new,
    // then tracks orieff (= raw sum) separately for createcpmxresult blending.
    let w: Vec<f64> = group.iter().map(|&i| weights[i]).collect();
    let sum: f64 = w.iter().sum();
    let wn: Vec<f64> = if sum > 0.0 { w.iter().map(|v| v / sum).collect() } else { vec![1.0; group.len()] };
    let prof = Profile::from_aligned(&seqs, &wn, &scoring.amino_map, scoring.nalphabets);
    (prof, sum)
}

/// `--c-compat` variant of `build_profile_from_seqs` that uses
/// `Profile::from_aligned_with_memo` so the thread-local cpmx memo
/// can fire when conditions match. `firstmem` is `group[0]` (the
/// global leaf index of the cluster's first member, matching C's
/// `localmem[0][0]`); `icyc` is the cluster size; `lgth` is the
/// per-sequence width.
fn build_profile_with_memo(
    group: &[usize],
    aligned: &[Vec<u8>],
    weights: &[f64],
    scoring: &ScoringContext,
) -> (Profile, f64) {
    let seqs: Vec<&[u8]> = group.iter().map(|&i| aligned[i].as_slice()).collect();
    let w: Vec<f64> = group.iter().map(|&i| weights[i]).collect();
    let sum: f64 = w.iter().sum();
    let wn: Vec<f64> = if sum > 0.0 { w.iter().map(|v| v / sum).collect() } else { vec![1.0; group.len()] };
    let firstmem = group[0] as i32;
    let icyc = group.len();
    let lgth = seqs.first().map_or(0, |s| s.len());
    let prof = Profile::from_aligned_with_memo(
        &seqs, &wn, &scoring.amino_map, scoring.nalphabets,
        firstmem, icyc, lgth,
    );
    (prof, sum)
}

/// Blend two profiles using C's exact createcpmxresult + creategapfreqresult +
/// createogresult + createfgresult logic (MSalignmm.c lines 283-467).
///
/// The ogcp/fgcp blending handles gap positions specially: at block boundaries
/// (gap→non-gap or non-gap→gap), the value is interpolated from the source
/// profile's nongap_freq. Within a gap block, the value is 0.
/// Blend two profiles. Exposed `pub` for FFI cross-validation against
/// C's `createcpmxresult + creategapfreqresult + createogresult +
/// createfgresult` (`Salignmm.c:608-823`).
pub fn blend_profiles_exact(
    prof1: &Profile,
    prof2: &Profile,
    eff1: f64,
    eff2: f64,
    gaptable1: &[u8],
    gaptable2: &[u8],
    nalphabets: usize,
) -> Profile {
    let alen = gaptable1.len();
    let mut freqs = vec![vec![0.0f64; nalphabets]; alen];
    let mut nongap_freq = vec![0.0f64; alen + 1]; // C uses alen+1
    let mut ogcp = vec![0.0f64; alen];
    let mut fgcp = vec![0.0f64; alen];

    // createcpmxresult: blend frequency matrices.
    // FMA throughout: matches gcc's `-O3` fusion of `a + b*c` so the blended
    // child-profile is bit-identical to C's. Without FMA the per-column
    // weighted frequency accumulates 1-ULP differences that propagate into
    // match_calc_row and flip DP tie-breaks for flat-landscape matrices
    // (TM PAM 200 — §B.2).
    {
        let mut p = 0usize;
        for j in 0..alen {
            if gaptable1[j] != b'-' {
                if p < prof1.length {
                    for k in 0..nalphabets.min(prof1.freqs[p].len()) {
                        freqs[j][k] = fmadd(prof1.freqs[p][k], eff1, freqs[j][k]);
                    }
                }
                p += 1;
            }
        }
    }
    {
        let mut p = 0usize;
        for j in 0..alen {
            if gaptable2[j] != b'-' {
                if p < prof2.length {
                    for k in 0..nalphabets.min(prof2.freqs[p].len()) {
                        freqs[j][k] = fmadd(prof2.freqs[p][k], eff2, freqs[j][k]);
                    }
                }
                p += 1;
            }
        }
    }

    // creategapfreqresult: blend nongap frequencies (C uses alen+1 positions)
    {
        let mut p = 0usize;
        for j in 0..=alen {
            if j < alen && gaptable1[j] == b'-' {
                // gap position: skip
            } else {
                if p < prof1.nongap_freq.len() {
                    nongap_freq[j] = fmadd(prof1.nongap_freq[p], eff1, nongap_freq[j]);
                }
                p += 1;
            }
        }
    }
    {
        let mut p = 0usize;
        for j in 0..alen {
            if gaptable2[j] == b'-' {
                // gap position: skip
            } else {
                if p < prof2.nongap_freq.len() {
                    nongap_freq[j] = fmadd(prof2.nongap_freq[p], eff2, nongap_freq[j]);
                }
                p += 1;
            }
        }
    }
    nongap_freq[alen] = 1.0; // C: gapfresult[j] = 1.0 at tail

    // createogresult: blend opening gap counts with block-boundary handling
    blend_og_one_side(&mut ogcp, &prof1.ogcp, &prof1.nongap_freq, gaptable1, eff1);
    blend_og_one_side(&mut ogcp, &prof2.ogcp, &prof2.nongap_freq, gaptable2, eff2);

    // createfgresult: blend closing gap counts with block-boundary handling
    blend_fg_one_side(&mut fgcp, &prof1.fgcp, &prof1.nongap_freq, gaptable1, eff1);
    blend_fg_one_side(&mut fgcp, &prof2.fgcp, &prof2.nongap_freq, gaptable2, eff2);

    // Compute gap_freq from nongap_freq
    let gap_freq: Vec<f64> = nongap_freq[..alen].iter().map(|&nf| (1.0 - nf).max(0.0)).collect();
    let nongap_freq_trimmed = nongap_freq[..alen].to_vec();

    Profile {
        freqs,
        gap_freq,
        nongap_freq: nongap_freq_trimmed,
        ogcp,
        fgcp,
        length: alen,
        nalphabets,
    }
}

/// C's createogresult logic for one side (MSalignmm.c lines 354-378).
fn blend_og_one_side(
    result: &mut [f64],
    ori: &[f64],     // raw opening counts
    gf: &[f64],      // nongap_freq
    gaptable: &[u8],
    eff: f64,
) {
    let alen = result.len();
    let mut p = 0usize;
    for j in 0..alen {
        if gaptable[j] == b'-' {
            if j == 0 {
                result[j] += eff;
            } else if gaptable[j - 1] != b'-' && p > 0 {
                let gf_val = if p - 1 < gf.len() { gf[p - 1] } else { 1.0 };
                result[j] = fmadd(gf_val, eff, result[j]);
            }
        } else {
            if j == 0 || (j > 0 && gaptable[j - 1] != b'-') {
                if p < ori.len() {
                    result[j] = fmadd(ori[p], eff, result[j]);
                }
            }
            p += 1;
        }
    }
}

/// C's createfgresult logic for one side (Salignmm.c lines 782-823).
///
/// IMPORTANT: C reads `gaptable1[j+1]` even at j = alen-1, accessing
/// the null terminator past the end of the C string (which is `!= '-'`).
/// So at the LAST non-gap position, C ALWAYS adds `ori[p] * eff` (the
/// closing-count contribution), since the "next" position is treated as
/// non-gap. Our Rust gaptable is a `&[u8]` without a null terminator,
/// so we treat the out-of-bounds j+1 as non-gap to match. The prior
/// `j < alen - 1` short-circuit dropped this contribution at the
/// alignment's last position, causing pass-1 BB20027 cpmx drift when
/// the merged profile was cached and reused at the next merge.
fn blend_fg_one_side(
    result: &mut [f64],
    ori: &[f64],     // raw closing counts
    gf: &[f64],      // nongap_freq
    gaptable: &[u8],
    eff: f64,
) {
    let alen = result.len();
    let mut p = 0usize;
    // C treats out-of-bounds `gaptable[alen]` as non-gap (the `\0` of
    // the null-terminated string, which is != '-').
    let next_is_gap = |j: usize| -> bool {
        if j + 1 < alen { gaptable[j + 1] == b'-' } else { false }
    };
    for j in 0..alen {
        if gaptable[j] == b'-' {
            if j == alen - 1 {
                result[j] += eff;
            } else if !next_is_gap(j) {
                let gf_val = if p < gf.len() { gf[p] } else { 1.0 };
                result[j] = fmadd(gf_val, eff, result[j]);
            }
        } else {
            if !next_is_gap(j) {
                if p < ori.len() {
                    result[j] = fmadd(ori[p], eff, result[j]);
                }
            }
            p += 1;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use mafft_tree::{DistanceMatrix, upgma};
    use mafft_scoring::build_context;
    use mafft_types::{ScoringModel, SeqType};

    fn check_alignment(result: &MultipleAlignment, original: &[Vec<u8>]) {
        let width = result.width();
        assert!(width > 0);
        for (i, seq) in result.sequences.iter().enumerate() {
            assert_eq!(seq.len(), width, "seq {i} wrong width: {} vs {width}", seq.len());
            let ungapped: Vec<u8> = seq.iter().filter(|&&c| c != b'-').cloned().collect();
            assert_eq!(ungapped, original[i], "seq {i} residues not preserved");
        }
    }

    #[test]
    fn progressive_two_identical() {
        let scoring = build_context(ScoringModel::Blosum(62), SeqType::Protein);
        let seqs = vec![b"ACDEFGHIK".to_vec(), b"ACDEFGHIK".to_vec()];
        let names = vec!["s1".into(), "s2".into()];
        let mut dm = DistanceMatrix::new(2);
        dm.set(0, 1, 0.0);
        let topo = upgma(&dm);
        let result = progressive_align(&seqs, &names, &topo, &scoring, false, None);
        assert_eq!(result.sequences[0], result.sequences[1]);
        check_alignment(&result, &seqs);
    }

    #[test]
    fn progressive_three_sequences() {
        let scoring = build_context(ScoringModel::Blosum(62), SeqType::Protein);
        let seqs = vec![
            b"ACDEFGHIK".to_vec(),
            b"ACDEFHIK".to_vec(),
            b"ACDHIK".to_vec(),
        ];
        let names = vec!["s1".into(), "s2".into(), "s3".into()];
        let mut dm = DistanceMatrix::new(3);
        dm.set(0, 1, 0.1); dm.set(0, 2, 0.3); dm.set(1, 2, 0.2);
        let topo = upgma(&dm);
        let result = progressive_align(&seqs, &names, &topo, &scoring, false, None);
        check_alignment(&result, &seqs);
    }

    #[test]
    fn progressive_six_sequences_preserves_residues() {
        let scoring = build_context(ScoringModel::Blosum(62), SeqType::Protein);
        let seqs = vec![
            b"ACDEFGHIKLMNPQR".to_vec(),
            b"ACDEFHIKLMNPQR".to_vec(),
            b"ACDEHIKLMNPQR".to_vec(),
            b"ACDHIKLMNPQR".to_vec(),
            b"ACDHIKLMNP".to_vec(),
            b"ACDHIKLM".to_vec(),
        ];
        let names: Vec<String> = (0..6).map(|i| format!("s{i}")).collect();
        let mut dm = DistanceMatrix::new(6);
        for i in 0..6 { for j in (i+1)..6 { dm.set(i, j, (j-i) as f64 * 0.1); } }
        let topo = upgma(&dm);
        let result = progressive_align(&seqs, &names, &topo, &scoring, false, None);
        check_alignment(&result, &seqs);
    }
}