ferro-hgvs 1.0.0

HGVS variant normalizer - part of the ferro bioinformatics toolkit
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
//! Apply a variant to its reference, and derive an encoding-invariant key from
//! the result (#1159).
//!
//! # Why this exists
//!
//! [`crate::spdi::hgvs_to_spdi`] converts one description into one SPDI triple.
//! That is a *transliteration*: it preserves however the caller chose to
//! partition the change, so two descriptions of the same edit convert to
//! different triples. `TEMPLATE:g.8_14delinsGATTA` and
//! `TEMPLATE:g.[8A>G;9G>A;11C>T;13_14del]` denote the same resulting sequence and
//! transliterate to one triple and four.
//!
//! Callers who want a key that is equal **iff two descriptions denote the same
//! edit** therefore cannot use it, and #1159 records what they do instead:
//! normalize both and compare the strings.
//!
//! That was failing when #1159 was filed, for the reasons #1157 and #1158
//! recorded. **Both are now closed and fixed on `main`** — #1157 by #1160/#1161
//! and #1158 by #1237/#1341 — and the normalizer is measurably confluent on
//! #1157's own shape today. So the argument for this module is no longer "the
//! normalize-and-compare route is broken"; it is that the route couples a key to
//! the normalizer, and every future confluence fix then churns stored keys.
//! `canonical_spdi` has no normalizer dependency at all, which is the property
//! worth having.
//!
//! [`canonical_spdi`] answers the question directly instead: apply every member
//! to the reference, then read the difference back out of the *resulting bases*.
//! Partitioning cannot survive that, because it is not represented in the
//! sequence.
//!
//! # What the key does and does not guarantee
//!
//! **Guaranteed:** two variants on one accession with the same resulting
//! sequence produce the same `SpdiVariant`, whatever their spelling, member count
//! or member order. That is the property #1159 asks for and what the tests pin.
//!
//! **Also guaranteed, as of the 3' padding:** the change is rolled to its
//! 3'-most equivalent position. A blunt trim over a window wide enough to
//! contain the roll *is* the maximal 3' shift, so `g.3_5del` and `g.4_6del` —
//! the same deletion spelled one base apart in a tract — now key identically.
//! Before the window was padded they did not, and that was the defect: the
//! window was exactly the members' own span, leaving the trim no room to move.
//!
//! **Also guaranteed, as of the alphabet fold:** the key does not depend on the
//! provider's spelling conventions. Its bases are emitted uppercase, and `U` is
//! folded to `T` on the `r.` axis, so a soft-masked FASTA and an uppercase one
//! key the same locus identically. Stated here because it is a guarantee a
//! *caller* has, while the mechanism is a private helper (`key_alphabet`) that
//! rustdoc does not publish — read that helper for the one boundary the fold
//! does not reach, a provider serving a uracil-spelled transcript.
//!
//! **Still not claimed:** byte-compatibility with the SPDI specification's own
//! canonical form. The form targeted here is **3'-maximal**, per `general.md:34`;
//! NCBI's is extended in *both* directions over the repeat, so the two differ on
//! a rolled indel — `9:G:` here against `8:GG:G` there. Equality across spellings
//! is the contract, not agreement with another implementation.
//!
//! **Not claimed either:** that a no-op carries a canonical position. `g.3A>A`
//! and `g.5T>T` both change nothing but key as `3::` and `5::`. Giving that one
//! answer means choosing one, and nothing here needs it.

use crate::error::FerroError;
use crate::hgvs::variant::HgvsVariant;
use crate::normalize::footprint::WriteFootprint;
use crate::reference::ReferenceProvider;
use crate::spdi::convert::{apply_alphabet, AlphabetMode};
use crate::spdi::SpdiVariant;

/// A variant applied to its reference.
///
/// The window is the union of every member's footprint, so `reference` and
/// `resulting` are directly comparable strings of the same locus before and
/// after. `resulting` is what #1159 calls the ground truth for equivalence.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppliedVariant {
    /// The accession every member acts on.
    pub accession: String,
    /// 0-based start of the window both strings cover.
    pub start: u64,
    /// The reference bases over the window.
    pub reference: String,
    /// The same window after every member has been applied.
    pub resulting: String,
}

/// Largest window, in bases, that [`apply_to_reference`] will fetch.
///
/// A variant spanning more than this is declined rather than served: the point
/// of the primitive is a comparable local window, and a caller asking about a
/// megabase deletion wants a different tool. Mirrors the equivalence checker's
/// own bound, for the same reason.
pub const MAX_APPLY_WINDOW: u64 = 100_000;

/// Longest repeat tract [`canonical_spdi`] will roll a change 3' through.
///
/// Named separately from [`MAX_APPLY_WINDOW`] because it bounds a different
/// thing, and saying so is the point. `MAX_APPLY_WINDOW` answers "how much
/// reference will I read for this variant", which the caller can predict from
/// the description alone. This one answers "how long a tract will I tolerate
/// *around* it", which the caller cannot predict — it is a property of the
/// variant's neighbourhood in the reference.
///
/// That makes a previously total function partial on a new axis, which is the
/// strongest argument against extending the window at all. The mitigation is
/// this: its own name, a generous size, and its own error variant, so the
/// failure reads as "this variant sits in a repeat tract longer than N" rather
/// than as a generic decline the caller must guess the cause of.
///
/// 32 KB is far above any tract a key is meaningful for — the longest known
/// pathogenic expansions run to a few tens of kilobases, and a change that rolls
/// that far is a structural event rather than the local edit this key describes.
pub const MAX_SHIFT_TRACT: u64 = 32_768;

/// Apply every member of `variant` to `provider`'s reference and return the
/// before/after window.
///
/// # Errors
///
/// Declines — rather than guessing — when a single resulting sequence is not
/// well defined: a non-cis allele (trans / mosaic / chimeric / unknown phase, all
/// of which describe more than one molecule), a null or unknown allele, members
/// on different accessions, members whose edits SPDI cannot represent, members
/// that overlap (applying them depends on order, so there is no one answer), a
/// stated deletion that disagrees with the reference, or a window wider than
/// [`MAX_APPLY_WINDOW`].
pub fn apply_to_reference<P: ReferenceProvider + ?Sized>(
    variant: &HgvsVariant,
    provider: &P,
) -> Result<AppliedVariant, FerroError> {
    apply_to_reference_padded(variant, provider, 0).map(|(applied, _)| applied)
}

/// As [`apply_to_reference`], but fetching `pad_3prime` extra reference bases
/// past the members' own span, and reporting whether the full pad was served.
///
/// The pad is what gives [`canonical_spdi`]'s blunt trim room to roll a change
/// 3'. **Only the 3' side is padded, and that is not an oversight.** Prepending
/// `n` bases of 5' flank adds exactly `n` to the common prefix, so
/// `position = start + prefix` is unchanged — left context cannot move the key,
/// and fetching it would be cost with no effect.
///
/// The returned flag says the window is **final**: it ends where the pad asked,
/// or where the sequence itself ends. False means the contig ran out first, so
/// a change still touching the 3' edge has genuinely nowhere left to roll.
///
/// A provider that can serve neither the wider span nor a length to bound it by
/// is a third case, and it declines rather than reporting either — the caller
/// could not otherwise tell "the change stops here" from "I could not read far
/// enough to find out", and would key off a window that may have cut the roll
/// short.
pub(crate) fn apply_to_reference_padded<P: ReferenceProvider + ?Sized>(
    variant: &HgvsVariant,
    provider: &P,
    pad_3prime: u64,
) -> Result<(AppliedVariant, bool), FerroError> {
    // `UnsupportedVariant` carries only a `variant_type` string, so the variant
    // and the reason both go in it — a caller that cannot apply a description
    // needs to know which one and why, and a bare type name would say neither.
    let decline = |reason: &str| FerroError::UnsupportedVariant {
        variant_type: format!("{variant}: cannot apply to reference — {reason}"),
    };

    let (accession, triples) = variant_edit_triples(variant, provider).ok_or_else(|| {
        decline(
            "no single resulting sequence is defined for it — it is a multi-molecule \
             or null allele, spans more than one accession, or carries an edit SPDI \
             cannot represent",
        )
    })?;

    let (mut start, mut end) = (u64::MAX, u64::MIN);
    for triple in &triples {
        start = start.min(triple.position);
        // Checked: `position` is provider-derived and `deletion` caller-derived,
        // so nothing upstream bounds their sum. A wrap here would silently
        // shrink the window and key the variant off the wrong bases.
        let triple_end = triple
            .position
            .checked_add(triple.deletion.len() as u64)
            .ok_or_else(|| decline("its span overflows the coordinate space"))?;
        end = end.max(triple_end);
    }
    if start > end {
        return Err(decline("its members name no reference span"));
    }
    if end - start > MAX_APPLY_WINDOW {
        return Err(decline(&format!(
            "it spans {} bases, more than the {MAX_APPLY_WINDOW}-base limit",
            end - start
        )));
    }

    // Clamp the pad to the contig when its length is knowable. A failed lookup
    // is not fatal: `get_sequence_length` carries a trait default that always
    // errors (`provider.rs:424`), so an out-of-tree provider that serves bases
    // perfectly well may not implement it, and refusing there would regress a
    // caller that works today. The padded fetch is simply attempted and, if the
    // provider cannot serve it, the unpadded window is used instead.
    let requested_end = end.saturating_add(pad_3prime);
    let known_length = provider.get_sequence_length(&accession).ok();
    let wanted_end = known_length.map_or(requested_end, |length| requested_end.min(length));

    let reference = fetch_window(provider, &accession, start, wanted_end).ok_or_else(|| {
        // Falling back to the unpadded window here would be the wrong kind of
        // safe. The caller cannot then tell "the change genuinely stops here"
        // from "I could not read far enough to find out", and would key the
        // variant off a window that may have cut the roll short — two spellings
        // either side of the cut keying differently, which is the exact
        // non-convergence this padding removes. Declining says so instead.
        if wanted_end > end {
            decline(
                "its reference window could not be widened far enough to settle where the \
                 change ends — the provider served neither the wider span nor a length to \
                 bound it by",
            )
        } else {
            decline("its reference window could not be read")
        }
    })?;

    // The roll is complete when the window ends where we asked, or where the
    // sequence itself ends. Only a *known* length makes the second case
    // trustworthy: without one, a short window is indistinguishable from a
    // provider that simply stopped, which is why the fetch above declines
    // rather than falling back.
    let window_is_final =
        wanted_end == requested_end || known_length.is_some_and(|length| wanted_end == length);

    let resulting = apply_triples(&reference, start, &triples).ok_or_else(|| {
        decline(
            "its members overlap, or a stated reference base disagrees with the \
             reference",
        )
    })?;

    Ok((
        AppliedVariant {
            accession,
            start,
            reference,
            resulting,
        },
        window_is_final,
    ))
}

/// An encoding-invariant SPDI key for `variant`, derived from the bases it
/// results in rather than from how it was written (#1159).
///
/// Two descriptions on one accession denoting the same resulting sequence give
/// the same triple, whatever their spelling, member count or member order. See
/// the module docs for what this does and does not claim.
///
/// # Errors
///
/// As [`apply_to_reference`].
pub fn canonical_spdi<P: ReferenceProvider + ?Sized>(
    variant: &HgvsVariant,
    provider: &P,
) -> Result<SpdiVariant, FerroError> {
    /// First pad tried once a change is found to reach the window's 3' edge.
    /// Doubling from here makes a homopolymer cost `O(log tract)` fetches, and
    /// covers every non-repeat case in one.
    const FIRST_PAD: u64 = 64;

    let alphabet = key_alphabet(variant);
    let mut pad = 0u64;
    loop {
        let (applied, window_is_final) = apply_to_reference_padded(variant, provider, pad)?;
        let (offset, deletion, insertion) =
            trim_common_flanks(applied.reference.as_bytes(), applied.resulting.as_bytes());

        // A description that changes nothing has no block to roll, and the
        // 3'-edge test below is vacuously true for it — every base matches, so
        // the trimmed block sits at the window's end however wide the window is.
        // Without this the loop would widen to the accession's end chasing an
        // edge it can never leave, then decline a variant that is merely inert.
        //
        // It does **not** make two no-ops agree, and measuring rather than
        // assuming is what showed that: `g.3A>A` keys as `3::` and `g.5T>T` as
        // `5::`, each at its own position. That is a real residual — a key
        // meaning "nothing changed" arguably should not carry a position at all
        // — but it is pre-existing, orthogonal to the 3'-shift this change is
        // about, and giving it an answer means choosing one, so it is left
        // stated rather than silently picked.
        let is_no_op = deletion.is_empty() && insertion.is_empty();

        // The blunt trim *is* the maximal 3' shift, but only over a window wide
        // enough to contain the roll. While the trimmed block still runs to the
        // window's 3' edge, the shift may have been cut off by the window rather
        // than by the sequence, so widen and ask again.
        let reaches_edge = offset + deletion.len() == applied.reference.len();

        if is_no_op || !reaches_edge || !window_is_final {
            // Folded through `apply_alphabet`, not emitted raw: see
            // `key_alphabet` for why a raw window makes the key depend on the
            // provider's case convention rather than on the bases.
            return Ok(SpdiVariant {
                sequence: applied.accession,
                position: applied.start + offset as u64,
                deletion: apply_alphabet(&String::from_utf8_lossy(deletion), alphabet),
                insertion: apply_alphabet(&String::from_utf8_lossy(insertion), alphabet),
            });
        }

        // Decline rather than answer over a truncated window. Returning the
        // clamped key would not be a smaller answer, it would be a *different*
        // one: two spellings either side of the cap key differently, which is
        // exactly the non-convergence this function exists to remove.
        if pad >= MAX_SHIFT_TRACT {
            return Err(FerroError::UnsupportedVariant {
                variant_type: format!(
                    "{variant}: cannot derive a stable key — it sits in a repeat tract \
                     still running past {MAX_SHIFT_TRACT} bases 3' of it, so how far the \
                     change shifts depends on how much reference is read"
                ),
            });
        }
        pad = if pad == 0 {
            FIRST_PAD
        } else {
            pad.saturating_mul(2)
        };
    }
}

/// The alphabet convention [`canonical_spdi`] renders a key's bases in.
///
/// **A key must not depend on the provider's spelling conventions**, and without
/// this fold it did. The two halves of a key come from two places: the inserted
/// payload is carried in by the triples, which [`crate::spdi::hgvs_to_spdi`] has
/// already run through [`apply_alphabet`] with the member's own axis, while the
/// deleted bases — and any inserted bases a `dup` or `inv` reads *out of* the
/// reference — come from the fetched window verbatim. So one key could hold two
/// conventions at once, and two providers serving the same sequence could key it
/// differently.
///
/// Measured before this fold, on a soft-masked copy of the module's own fixture
/// contig: `g.3_7delinsGGCTA` keyed as `2:ATTAC:GGCTA` against the uppercase
/// contig and as `2:attac:GGCTA` against the lowercase one. Real genomic FASTAs
/// lowercase repeat tracts, so that is the common case rather than a corner:
/// `SpdiKey`'s whole contract is that equal keys mean equal bases, and case
/// carries no biological meaning. `trim_common_flanks` was already
/// case-insensitive, so only the *emitted* strings were affected — the position
/// and the block boundaries were right all along, which is what made this
/// invisible to every test using an uppercase fixture.
/// `a_soft_masked_reference_keys_the_same_as_an_uppercase_one` pins it.
///
/// [`AlphabetMode::Rna`] additionally rewrites `U` to `T`, the same convention
/// the `r.` axis's triples already carry. **That reconciles a uracil-spelled
/// provider only where the U-bearing reference bases reach the emitted block
/// through [`trim_common_flanks`], and the boundary is worth stating rather than
/// generalising from** — the fold runs *after* both comparisons that decide the
/// block, and each is `eq_ignore_ascii_case` only, which does not relate `U` to
/// `T`. Measured on one 40-base transcript served both ways:
///
/// ```text
///                        T-spelled provider   U-spelled provider
///   r.[3a>g;7c>a]        2:ATTAC:GTTAA        2:ATTAC:GTTAA   agrees — the fold
///   r.3a>g               2:A:G                2:A:G           agrees
///   r.14dup              14::T                14::T           agrees
///   r.13_14insu          14::T                13::T           position shifts
///   r.3_5del             3:TTA:               declined        apply_triples
///   r.3_7delinsggcua     2:ATTAC:GGCTA        declined        apply_triples
/// ```
///
/// The two failing shapes are bounded by named code, not by luck. A **stated**
/// deletion is validated against the reference in [`apply_triples`] before any
/// fold, so `T` against `U` reads as a ref-mismatch and the variant is declined
/// outright. An insertion or `dup` rolling 3' has its common-prefix scan stopped
/// at the first `U`/`T` disagreement, so the roll is cut short and the *position*
/// moves. Neither is reachable from a RefSeq-spelled provider — `refseq.md` and
/// [`apply_alphabet`] both record that transcript sequences are stored as DNA —
/// which is why this is left stated rather than fixed here: making the two
/// comparisons alphabet-aware is a change to the `n.`/`c.` paths as well.
/// `the_alphabet_fold_reconciles_case_and_bounds_the_uracil_case` pins every row
/// above.
///
/// Note this deliberately does **not** fold [`AppliedVariant`], whose whole
/// point is to hand back the reference window as it was served; a caller
/// inspecting soft-masking there is asking a legitimate question. Nothing
/// compares a folded key against an unfolded window: [`EquivalenceChecker`]
/// compares two [`apply_triples`] results with each other, and
/// `examples/dump_normalized_corpus.rs` compares `reference` against the frame
/// it generated — neither reads a [`SpdiVariant`] field.
///
/// [`EquivalenceChecker`]: crate::equivalence::EquivalenceChecker
fn key_alphabet(variant: &HgvsVariant) -> AlphabetMode {
    fn is_rna(variant: &HgvsVariant) -> bool {
        matches!(variant, HgvsVariant::Rna(_))
    }
    match variant {
        // `any` rather than `all`, and the distinction is **reachable** — not, as
        // this comment once claimed, ruled out by the members sharing one
        // accession. `[NR_X.1:r.3a>g;NR_X.1:n.7C>A]` parses, is one cis allele on
        // one accession, and keys; measured on a U-spelled provider it keys
        // `2:ATTAC:GTTAA` under `any` and `2:AUUAC:GUUAA` under `all`. So `any`
        // is what stops one key from carrying two alphabets, which is the whole
        // point of the fold. Pinned by
        // `a_mixed_axis_cis_allele_folds_on_any_rna_member`.
        HgvsVariant::Allele(allele) if allele.variants.iter().any(is_rna) => AlphabetMode::Rna,
        single if is_rna(single) => AlphabetMode::Rna,
        _ => AlphabetMode::Dna,
    }
}

/// Strip the bases the two windows share at each end, returning the offset of the
/// remaining block and the two differing slices.
///
/// This is what makes the key window-independent: a wider window differs from a
/// narrower one only by flanking bases that are identical on both sides, and
/// those are exactly what is removed. Case-insensitive, because reference FASTAs
/// are often soft-masked and case carries no biological meaning — the same
/// reasoning `apply_triples` already applies to stated deletions.
fn trim_common_flanks<'a>(reference: &'a [u8], resulting: &'a [u8]) -> (usize, &'a [u8], &'a [u8]) {
    let max_prefix = reference.len().min(resulting.len());
    let mut prefix = 0;
    while prefix < max_prefix && reference[prefix].eq_ignore_ascii_case(&resulting[prefix]) {
        prefix += 1;
    }
    let mut suffix = 0;
    while suffix < max_prefix - prefix
        && reference[reference.len() - 1 - suffix]
            .eq_ignore_ascii_case(&resulting[resulting.len() - 1 - suffix])
    {
        suffix += 1;
    }
    (
        prefix,
        &reference[prefix..reference.len() - suffix],
        &resulting[prefix..resulting.len() - suffix],
    )
}

/// Apply SPDI triples to `reference` — the bases spanning the interbase
/// interval that begins at `win_start` — and return the edited sequence.
///
/// Triples are applied from the 3' end (descending position) so that an earlier
/// splice never shifts the coordinates of a later one, and — among triples that
/// share a position — **longer deletions first**, so a span-claiming member is
/// always applied before the zero-length one flush against it. The second
/// component is not cosmetic: [`triples_are_disjoint`] walks this slice in
/// reverse and would otherwise report an insertion abutting a `sub`/`del` as an
/// overlap or not depending on the order the caller's members were written in
/// (#1831). [`splice_denoted_sequence`] and `conformance::spec_corpus`'s own
/// applier both sort the same way.
///
/// Each triple's stated
/// deleted bases are validated against the actual reference bases at that span;
/// if they disagree (a ref-mismatched input — e.g. `c.5A>G` where the reference
/// base is not `A`), we cannot faithfully reconstruct the edit, so we decline
/// (`None`) rather than assert a sequence equivalence we cannot trust. Also
/// returns `None` if two triples overlap (see [`triples_are_disjoint`]), or if
/// the window the caller handed over does not cover the triples it handed over
/// with it — which is a broken precondition of this function rather than a fact
/// about any description. [`ApplyDecline`] keeps the three apart for the caller
/// that needs to tell them apart; this wrapper flattens all three.
pub(crate) fn apply_triples(
    reference: &str,
    win_start: u64,
    triples: &[SpdiVariant],
) -> Option<String> {
    apply_triples_classified(reference, win_start, triples).ok()
}

/// Why [`apply_triples`] could not reconstruct the edited sequence.
///
/// The split exists so a consumer can tell a **reference-level** obstacle from a
/// **description-level** one, and both of those from a **broken precondition of
/// the applier** — three things that must never be one, which is the whole point
/// of #1989/#2053/#2069, made here at the apply step. [`apply_triples`] itself
/// flattens all three to `None` for the callers that do not care.
///
/// # The classification is a total function, tested over the enum (#2104)
///
/// Which sequence verdict a decline reports is `into_sequence_verdict` in
/// `src/equivalence/checker.rs` — there rather than here, because the verdict is
/// the checker's own private type and `spdi` must not depend on `equivalence` —
/// and which of two declines governs when both sides declined is
/// [`Self::governing`]. Both are wildcard-free matches over this enum, so a
/// fourth variant is a compile error at every place that has to decide what it
/// means rather than a silent catch-all.
///
/// Both are also tested by **constructing each variant directly**, not only by
/// driving the pipeline, because for two of the three the pipeline cannot pin
/// them at all. Measured on this branch, over the whole suite: a `panic!()` in
/// the checker's decline arm *and* in all five of this function's contract
/// guards survived `11405 tests run: 11405 passed`, and with the classification
/// carried by ordered match arms both mutations that matter were invisible —
/// over-mapping a decline onto another's verdict, and reversing the precedence,
/// each left `11405 passed` (#2104).
///
/// Where each variant can and cannot be reached, measured rather than assumed:
///
/// * [`Self::ReferenceMismatch`] is reached end-to-end and pinned by
///   `issue_2075_apply_triples_reference_mismatch`.
/// * [`Self::MembersOverlap`] is reachable, but **only through
///   `EquivalenceChecker::compare_denotations`**, which compares the pair as
///   written. `EquivalenceChecker::check` normalizes first, and normalization
///   *repairs* the overlap: measured, `NC_TEST.1:g.[267_270AT[4];270_276del]`
///   normalizes to the single-member `NC_TEST.1:g.270_272del`, and one member
///   cannot overlap anything. That is why #1244's five tests — all of which use
///   `check` — never reach this arm, and why the claim that they pinned it was
///   withdrawn. It is now pinned end-to-end by the `compare_denotations` test in
///   the same file.
/// * [`Self::ContractViolated`] is unreachable from either caller by
///   construction, so the enum is the *only* place its mapping can be asserted.
///   `compare_triples` additionally asserts against it in debug.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ApplyDecline {
    /// A triple's stated deleted bases contradict the bases the reference
    /// actually carries at that span — a ref-mismatched input (e.g. `g.5A>G`
    /// where the served base is not `A`). The fetch succeeded, so this is not a
    /// missing-window failure; it is still "want of usable reference data",
    /// because the edit could not be faithfully reconstructed and nothing was
    /// compared. Must **not** be read as a decided negative (#2075).
    ReferenceMismatch,
    /// Two triples claim the same reference base, so the set cannot be spliced
    /// in one 3'->5' walk whatever the reference holds
    /// ([`triples_are_disjoint`], #1244).
    ///
    /// **The only decline here that is a fact about the description**, and so
    /// the only one a caller may read as a decided negative: an allele whose
    /// members overlap denotes no single resulting sequence, so there is nothing
    /// to compare and the description itself is what says so.
    MembersOverlap,
    /// A precondition of [`apply_triples_classified`] was broken by its caller:
    /// a triple lies outside `[win_start, win_start + reference.len())`, its
    /// coordinate arithmetic overflows, or the splice produced bytes that are
    /// not UTF-8.
    ///
    /// **Never a fact about the description, and so never a decided negative.**
    /// Both callers build the window as the union of the very triples they then
    /// hand over — `compare_triples` over both sides' triples,
    /// [`apply_to_reference_padded`] over one variant's — so a triple outside it
    /// means the *caller* mis-computed, and answering a decided negative would
    /// turn an internal inconsistency into a verdict about two descriptions.
    /// That is the class #2053/#2069/#2075 spent three PRs removing, which is
    /// why it is split out of #2075's own residual arm rather than left sitting
    /// inside it (#2104). It governs every other decline for the same reason:
    /// an internal inconsistency must not be masked by a decline about the
    /// descriptions.
    ContractViolated,
}

impl ApplyDecline {
    /// Every variant, exactly once — the corpus the classification tests walk.
    ///
    /// Kept honest by [`Self::precedence`], whose match is wildcard-free: a new
    /// variant is a compile error there, and
    /// `all_declines_are_listed_exactly_once` then fails until this array
    /// carries it too. Without both halves an "exhaustive over the enum" test
    /// would quietly stop being exhaustive — which is #2104's own failure, one
    /// level up.
    ///
    /// Test-only: it is a corpus, not part of the classification. The
    /// classification's own totality is carried by the wildcard-free matches,
    /// which are compiled in every configuration.
    #[cfg(test)]
    pub(crate) const ALL: [Self; 3] = [
        Self::ReferenceMismatch,
        Self::MembersOverlap,
        Self::ContractViolated,
    ];

    /// Which of two declines governs when **both** sides of a comparison
    /// declined.
    ///
    /// A `min` over [`Self::precedence`], so it is symmetric, idempotent and
    /// associative, and the precedence rule is a property of a pure function
    /// over the enum rather than of the order in which a caller's match arms
    /// happen to be written. Before #2104 it *was* that ordering, and reversing
    /// it was a mutation the entire suite could not see.
    pub(crate) fn governing(self, other: Self) -> Self {
        if other.precedence() < self.precedence() {
            other
        } else {
            self
        }
    }

    /// Rank in the governing order — **lower governs**, and the ranks are
    /// distinct, which is what makes [`Self::governing`] deterministic and
    /// `ALL` checkable.
    ///
    /// [`Self::ContractViolated`] is first so a broken precondition of this
    /// function is never masked by a decline about the descriptions.
    /// [`Self::ReferenceMismatch`] outranks [`Self::MembersOverlap`] because a
    /// difference cannot be asserted against a sequence that could not be built
    /// — the rule #2075 states in words and #2104 found unpinned.
    const fn precedence(self) -> u8 {
        match self {
            Self::ContractViolated => 0,
            Self::ReferenceMismatch => 1,
            Self::MembersOverlap => 2,
        }
    }
}

/// [`apply_triples`], but classifying *why* it declined — see [`ApplyDecline`].
pub(crate) fn apply_triples_classified(
    reference: &str,
    win_start: u64,
    triples: &[SpdiVariant],
) -> Result<String, ApplyDecline> {
    let ref_bytes = reference.as_bytes();
    let mut bytes = ref_bytes.to_vec();
    let mut ordered: Vec<&SpdiVariant> = triples.iter().collect();
    // Descending position, and at one position the **longer deletion first**.
    //
    // The length tie-break is not cosmetic (#1749). `sort_by_key` is stable, so
    // without it two triples sharing an interbase position keep their input
    // order, and an insertion flush against the 5' edge of a deletion is then
    // applied before it: the insertion's bases land at `rel`, and the
    // deletion's splice of `rel..rel + len` eats them and leaves one base of
    // the deletion behind. One variant, two resulting sequences, decided by
    // nothing the description states. Applying the deletion first is the order
    // that is correct for every tie — the deletion is the member that extends
    // 3', so taking it first preserves the "everything already applied sits 3'
    // of what is next" invariant this walk rests on.
    ordered.sort_by_key(|t| {
        (
            std::cmp::Reverse(t.position),
            std::cmp::Reverse(t.deletion.len()),
        )
    });
    if !triples_are_disjoint(&ordered) {
        return Err(ApplyDecline::MembersOverlap);
    }
    for t in ordered {
        // The three guards below are the caller's contract, not the
        // description's: the window is built from these very triples, so a
        // position 5' of it, a span that overflows, or a span running past its
        // 3' edge all mean the caller mis-computed. Classified as such so no
        // consumer can read an internal inconsistency as a decided verdict
        // (#2104).
        let rel = t
            .position
            .checked_sub(win_start)
            .ok_or(ApplyDecline::ContractViolated)? as usize;
        let end = rel
            .checked_add(t.deletion.len())
            .ok_or(ApplyDecline::ContractViolated)?;
        if end > ref_bytes.len() {
            return Err(ApplyDecline::ContractViolated);
        }
        // Validate the stated deletion against the original reference span.
        // (Checked against `ref_bytes`, not the mutated `bytes`: descending
        // order means every already-applied splice sits strictly 3' of `rel`,
        // so this span is untouched either way.)
        //
        // A disagreement is a reference-level obstacle, not a representation
        // one: the served reference is not the reference the description
        // asserts, so the edit cannot be faithfully reconstructed (#2075).
        if !ref_bytes[rel..end].eq_ignore_ascii_case(t.deletion.as_bytes()) {
            return Err(ApplyDecline::ReferenceMismatch);
        }
        // The splice targets the mutated buffer, whose length no longer matches
        // the reference once a length-changing edit has been applied. The
        // disjointness guard above already makes `end <= bytes.len()` hold;
        // bound it explicitly anyway so a future change cannot turn a logic
        // slip back into an out-of-bounds panic (#1244). A logic slip is this
        // function's own fault, hence `ContractViolated` and not a decline about
        // the description.
        if end > bytes.len() {
            return Err(ApplyDecline::ContractViolated);
        }
        bytes.splice(rel..end, t.insertion.bytes());
    }
    // `reference` arrived as a `&str` and every insertion came from a `String`,
    // so this can only fail if a splice cut a multi-byte character — again an
    // invariant of ours, not a statement about the description.
    String::from_utf8(bytes).map_err(|_| ApplyDecline::ContractViolated)
}

/// Whether `ordered` can be spliced in one 3' -> 5' walk.
///
/// That descending application order is what lets each stated deletion be
/// validated against the pristine reference: every already-applied splice sits
/// 3' of the next one, so the span about to be read is untouched. The argument
/// holds only while no triple's write obstructs another's, since overlapping
/// ones both invalidate that validation and can index past the end of the
/// shrinking buffer — the out-of-bounds panic of #1244.
///
/// Declining is also the honest answer semantically for the shapes it catches:
/// an allele whose members claim the same base has no single well-defined
/// resulting sequence, so there is nothing to compare. The caller uses the
/// comparison only to *upgrade* a `NotEquivalent` verdict, so a decline never
/// invents an equivalence.
///
/// # One definition, and one thing it deliberately is not (#1749)
///
/// The geometry is [`WriteFootprint`]'s, shared with `normalize::overlap` —
/// which is what makes "a `dup` writes at a junction, not over the span it
/// reads" one rule rather than one rule per module. SPDI arrives already in
/// those terms: a `dup` converts to `seq:101::ATG`, a triple with an **empty
/// deletion**, so it claims no base and cannot collide with one.
///
/// It answers the **splice algorithm's precondition**, which is not the same
/// question as "does this allele denote one sequence" — see
/// [`WriteFootprint::obstructs_splice_of`]. An insertion interior to a pure
/// deletion denotes one sequence perfectly well and still cannot be executed in
/// one walk, so it is declined here and is *not* a conflict in `overlap`.
///
/// Two triples that merely abut are disjoint, and so is an insertion flush
/// against either edge of a deletion. Any number of pure insertions at one
/// interbase position is likewise disjoint here — an insertion deletes nothing
/// and therefore claims no base — and the ordering ambiguity that shape does
/// carry is caught by `variant_edit_triples_reason` instead, for the reason
/// recorded on [`WriteFootprint::obstructs_splice_of`].
///
/// The parameter is still `ordered` for the caller's convenience, but the test
/// is now **pairwise and therefore order-independent**. The previous
/// implementation carried a running 3'-most `reach` and compared it against
/// each triple's position, which read a tie between a zero-width triple and a
/// span triple differently depending on which the stable sort happened to visit
/// first: `g.[5_9del;4_5insA]` was disjoint and `g.[4_5insA;5_9del]` was not.
/// `CLAUDE.md` records that class as 233 false fires of the denoted-sequence
/// oracle.
fn triples_are_disjoint(ordered: &[&SpdiVariant]) -> bool {
    let footprints: Vec<WriteFootprint<u64>> =
        ordered.iter().map(|t| triple_footprint(t)).collect();
    for (i, first) in footprints.iter().enumerate() {
        if footprints[i + 1..]
            .iter()
            .any(|second| first.obstructs_splice_of(second))
        {
            return false;
        }
    }
    true
}

/// An SPDI triple's write footprint, in **1-based base indices**.
///
/// SPDI positions are 0-based interbase: `position` names the gap *before* base
/// `position`, and a deletion of length `L` claims reference bytes
/// `[position, position + L)`. Adding one moves that onto a base index whose
/// junctions are non-negative, so a triple at interbase `0` has a representable
/// junction and the shared geometry needs no special case at the 5' terminus.
///
/// - a triple with an **empty deletion** claims no base and writes at the
///   junction 3' of base `position`;
/// - any other triple rewrites the closed base range
///   `position + 1 ..= position + deletion.len()`, and its bases survive only
///   if it puts something back.
fn triple_footprint(triple: &SpdiVariant) -> WriteFootprint<u64> {
    let length = triple.deletion.len() as u64;
    if length == 0 {
        return WriteFootprint::at_junction(triple.position);
    }
    WriteFootprint::spanning(
        triple.position + 1,
        triple.position + length,
        !triple.insertion.is_empty(),
    )
}

/// The SPDI triples that make up `variant`'s resulting sequence, and the single
/// accession they act on.
///
/// `None` when a single resulting sequence is undefined or cannot be derived —
/// see [`apply_to_reference`]'s error list, which this decides. Use
/// [`variant_edit_triples_reason`] when the *cause* matters.
pub(crate) fn variant_edit_triples<P: ReferenceProvider + ?Sized>(
    variant: &HgvsVariant,
    provider: &P,
) -> Option<(String, Vec<SpdiVariant>)> {
    variant_edit_triples_reason(variant, provider).ok()
}

/// Why a description yields no SPDI triple set.
///
/// The split exists because the two are **not equally severe**, and a caller
/// that cannot tell them apart draws the wrong conclusion from a decline. See
/// [`compare_denoted_sequences`], where reading every decline as
/// [`Self::SelfContradictory`] produced 328 false alarms across the test suite
/// in a single run.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NoTriples {
    /// The applier could not transliterate it: a shape with no single resulting
    /// sequence by construction (a trans allele, a null allele, members on
    /// different accessions), an edit SPDI cannot carry, a position it cannot
    /// resolve, or reference data the provider does not hold.
    ///
    /// **A limit of the applier or of the shape, never a verdict on the
    /// description.** The last cause is the common one and the one that misleads:
    /// `g.1000delC` states its deleted base and so converts with no provider at
    /// all, while the `g.1000del` a normalizer emits for it must read the
    /// reference — so on a provider holding no bases the input converts and the
    /// output does not, through no fault of the normalization.
    Untransliterable,
    /// The description states an edit set with no defined result **whatever the
    /// reference says** — members claiming the same base, or two insertions at
    /// one interbase with no stated order.
    ///
    /// A real fault in a description that claims to denote one sequence.
    SelfContradictory,
}

/// [`variant_edit_triples`], reporting why it declined.
pub(crate) fn variant_edit_triples_reason<P: ReferenceProvider + ?Sized>(
    variant: &HgvsVariant,
    provider: &P,
) -> Result<(String, Vec<SpdiVariant>), NoTriples> {
    use crate::hgvs::variant::AllelePhase;

    let members: Vec<&HgvsVariant> = match variant {
        HgvsVariant::Allele(allele) => {
            // A single resulting sequence is only well-defined for a cis allele —
            // every edit applied to the same molecule.
            if allele.phase != AllelePhase::Cis {
                return Err(NoTriples::Untransliterable);
            }
            allele.variants.iter().collect()
        }
        HgvsVariant::NullAllele | HgvsVariant::UnknownAllele => {
            return Err(NoTriples::Untransliterable)
        }
        single => vec![single],
    };
    if members.is_empty() {
        return Err(NoTriples::Untransliterable);
    }

    let mut accession: Option<String> = None;
    let mut triples = Vec::with_capacity(members.len());
    for member in members {
        let spdi =
            crate::spdi::hgvs_to_spdi(member, provider).map_err(|_| NoTriples::Untransliterable)?;
        match &accession {
            None => accession = Some(spdi.sequence.clone()),
            Some(acc) if *acc != spdi.sequence => return Err(NoTriples::Untransliterable),
            Some(_) => {}
        }
        triples.push(spdi);
    }

    // Two insertions at one interbase have no order between them, and this path
    // *publishes* a key — so it must decline rather than let the application
    // order pick a winner. Measured before this guard, on the fixture contig:
    //
    //     g.[5_6insA;5_6insC]  ->  5::CA
    //     g.[5_6insC;5_6insA]  ->  8::CA
    //
    // One variant, two keys, decided by nothing the description states. HGVS
    // spells "insert both, in this order" as a single ordered compound payload
    // (`ins[A;C]`, general.md:79), so a caller who means an order has a way to
    // say it and a caller who wrote two members has not said one.
    //
    // **Deliberately here and not in `triples_are_disjoint`.** That predicate is
    // shared with `EquivalenceChecker`, where the permissive reading is correct:
    // a decline there only forgoes upgrading a `NotEquivalent` verdict, so it can
    // never invent an equivalence, and two pinned tests depend on it. One
    // predicate cannot serve both a checker that may guess and a key that may
    // not.
    let mut zero_width: Vec<u64> = triples
        .iter()
        .filter(|t| t.deletion.is_empty())
        .map(|t| t.position)
        .collect();
    zero_width.sort_unstable();
    if zero_width.windows(2).any(|pair| pair[0] == pair[1]) {
        return Err(NoTriples::SelfContradictory);
    }

    accession
        .map(|acc| (acc, triples))
        .ok_or(NoTriples::Untransliterable)
}

/// Why two descriptions' denoted sequences could not be compared (#1615).
///
/// Every variant here is a limit of the *comparison*, not a verdict on either
/// description. They are enumerated rather than collapsed to one "skip" because
/// a skip that reads as a pass is the failure mode the denoted-sequence oracle
/// exists to remove: a caller that cannot say which of these it hit cannot tell
/// a clean run from a run that compared nothing.
///
/// Marked `#[non_exhaustive]` so a new decline reason is additive rather than a
/// breaking change, matching `SpdiParseError` and `ConversionError` in this
/// module. The set is demonstrably still growing:
/// [`Self::UnresolvableSpecialPosition`] exists only until `hgvs_to_spdi` stops
/// resolving `pter` silently — which as of #1643 is true of the **transcript**
/// axis only, the genomic half now being refused outright — and #1618/#1619 are
/// two more disagreements in flight.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum NotComparable {
    /// The **input** denotes no single sequence, so there is no baseline to
    /// compare the output against.
    ///
    /// A multi-molecule or null allele, members on different accessions, an edit
    /// SPDI cannot represent, a self-contradictory member set, or a stated
    /// reference base that disagrees with the reference (the `REFSEQ_MISMATCH`
    /// inputs normalization exists to correct). Blaming normalization for any of
    /// those would make a fire mean two different things — the discipline
    /// `assert_reparseable` and `assert_in_bounds` already apply.
    InputDenotesNoSequence,
    /// The **output** cannot be transliterated, though it is not
    /// self-contradictory. A limit of the applier or of the provider, not of the
    /// description — see [`NoTriples::Untransliterable`], which is exactly the
    /// asymmetry that makes this its own verdict rather than a fire.
    OutputUntransliterable,
    /// One side is an allele wrapped in the predicted marker `[(…)]`, whose
    /// members are uncertain by construction.
    UncertainAllele,
    /// One side names `pter`/`qter`/`cen`.
    ///
    /// Those carry no numeric coordinate — `GenomePos::pter()` and
    /// `CdsPos::pter()` both set `base: 0` — and comparing against a position
    /// that is not the one meant would report every correct `pter`
    /// normalization as a corruption. So `names_a_special_position` declines
    /// the pair on **both** axes, before either side is transliterated.
    ///
    /// **Only the transcript half is still resolved silently.** #1643 closed
    /// the genomic one: `hgvs_to_spdi` now refuses a `g.`/`m.`/`o.` special
    /// position with `ConversionError::InvalidPosition` (see
    /// `convert::reject_unresolvable_genomic_position`), so on that axis this
    /// decline has become belt-and-braces rather than the only thing standing
    /// between the oracle and a false fault. On the transcript axis `CdsPos`
    /// still flattens onto `base: 0` and `hgvs_to_spdi` still reads it as a
    /// coordinate — measured: `NM_003002.2:c.pterdel` transliterates to a
    /// deletion of the sequence's LAST base — which is the half this variant
    /// now exists for, and it retires when that half is fixed.
    UnresolvableSpecialPosition,
    /// The two descriptions name different accessions, so their sequences are
    /// not comparable at all. Normalization can do this legitimately (the #785
    /// transcript-version substitution).
    AccessionChanged,
    /// The union of the two descriptions' spans is wider than
    /// [`MAX_APPLY_WINDOW`].
    WindowTooWide,
    /// The provider could not serve the union window.
    ReferenceUnreadable,
}

impl std::fmt::Display for NotComparable {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let reason = match self {
            Self::InputDenotesNoSequence => "the input denotes no single sequence",
            Self::OutputUntransliterable => "the output cannot be transliterated to SPDI",
            Self::UncertainAllele => "one side is a predicted `[(…)]` allele",
            Self::UnresolvableSpecialPosition => "one side names pter/qter/cen",
            Self::AccessionChanged => "the two descriptions name different accessions",
            Self::WindowTooWide => "their union spans more than MAX_APPLY_WINDOW bases",
            Self::ReferenceUnreadable => "the provider could not serve the union window",
        };
        f.write_str(reason)
    }
}

/// The outcome of comparing the sequences two descriptions denote (#1615).
///
/// Deliberately **not** `#[non_exhaustive]`, unlike its
/// [`NotComparable`] payload: `issue_1615_denoted_sequence_oracle::
/// the_oracle_fires_on_every_recorded_defect` matches this enum exhaustively
/// from outside the crate, which is what makes a new verdict fail to compile
/// rather than be swallowed by a wildcard arm. A new *decline reason* is
/// additive and belongs in `NotComparable`; a new *verdict* is a change to what
/// the oracle can say, and every caller should have to answer for it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DenotedSequenceComparison {
    /// Both descriptions apply, and to the same bases.
    Agree,
    /// Both apply, and to **different** bases.
    Differ {
        /// The accession both descriptions act on.
        accession: String,
        /// 0-based start of the window both strings cover.
        start: u64,
        /// The reference bases over that window.
        reference: String,
        /// The window after applying the first description.
        from_input: String,
        /// The window after applying the second.
        from_output: String,
    },
    /// The **output** is self-contradictory although the input denotes a
    /// sequence: its members claim the same base, or two of its insertions share
    /// one interbase with no stated order.
    ///
    /// Deliberately *not* a [`NotComparable`] variant. #1281's `g.[1del;1del]`
    /// denotes nothing at all, which is strictly worse than denoting the wrong
    /// thing, so folding it in with the skips would hide the more severe defect
    /// behind the milder one. Equally deliberately, it is **narrower** than "the
    /// output produced no triples" — see [`NotComparable::OutputUntransliterable`].
    OutputContradictsItself,
    /// Neither verdict is available, for a stated reason.
    NotComparable(NotComparable),
}

/// Whether `variant` is an allele wrapped in the predicted marker `[(…)]`.
fn is_uncertain_allele(variant: &HgvsVariant) -> bool {
    matches!(variant, HgvsVariant::Allele(allele) if allele.uncertain)
}

/// Whether `variant` names a chromosome-arm or centromere special position.
///
/// Only [`crate::hgvs::location::GenomePos`] and
/// [`crate::hgvs::location::CdsPos`] carry the marker, so only the axes built on
/// those are walked; the rest cannot express one. See
/// [`NotComparable::UnresolvableSpecialPosition`] for why the answer matters.
fn names_a_special_position(variant: &HgvsVariant) -> bool {
    use crate::hgvs::interval::{Interval, UncertainBoundary};
    use crate::hgvs::location::{CdsPos, GenomePos};

    fn boundary_is_special<T>(boundary: &UncertainBoundary<T>, special: fn(&T) -> bool) -> bool {
        match boundary {
            UncertainBoundary::Single(mu) => mu.inner().is_some_and(special),
            UncertainBoundary::Range { start, end } => {
                start.inner().is_some_and(special) || end.inner().is_some_and(special)
            }
        }
    }
    fn interval_is_special<T>(interval: &Interval<T>, special: fn(&T) -> bool) -> bool {
        boundary_is_special(&interval.start, special) || boundary_is_special(&interval.end, special)
    }
    fn genomic(interval: &Interval<GenomePos>) -> bool {
        interval_is_special(interval, |p| p.special.is_some())
    }

    match variant {
        HgvsVariant::Allele(allele) => allele.variants.iter().any(names_a_special_position),
        HgvsVariant::Genome(v) => genomic(&v.loc_edit.location),
        HgvsVariant::Mt(v) => genomic(&v.loc_edit.location),
        HgvsVariant::Circular(v) => genomic(&v.loc_edit.location),
        HgvsVariant::Cds(v) => {
            interval_is_special(&v.loc_edit.location, |p: &CdsPos| p.special.is_some())
        }
        _ => false,
    }
}

/// Compare the sequences `input` and `output` denote against one reference
/// window (#1615).
///
/// This is the primitive behind the denoted-sequence seam oracle, and it is
/// **independent of the normalizer**: it reaches the bases through
/// [`variant_edit_triples_reason`] and [`apply_triples`], the same SPDI-splicing
/// walk [`apply_to_reference`] uses, so nothing here can agree with
/// normalization merely because normalization produced it. (`EquivalenceChecker`
/// cannot serve this role — it normalizes both sides, which is circular.)
///
/// Both descriptions are applied over the **union** of their two spans, one
/// fetch, so a 3'-shift is compared where it belongs: `g.3_4del` and `g.7_8del`
/// in a tract denote the same bases over any window containing both, and a
/// per-description window would give each its own frame and make them look
/// different. The comparison is ASCII-case-insensitive for the reason
/// [`trim_common_flanks`] gives — reference FASTAs are often soft-masked and
/// case carries no biological meaning, while one side's payload may be
/// reference-derived (a `dup`) and the other's a literal.
///
/// # Declining is the default; only two outcomes are faults
///
/// [`DenotedSequenceComparison::Differ`] and
/// [`DenotedSequenceComparison::OutputContradictsItself`] are the faults.
/// Everything else the applier cannot do is a [`NotComparable`] with a stated
/// reason, and that asymmetry is load-bearing rather than cautious: an earlier
/// revision reported *any* untranslatable output as a fault and raised **328**
/// false alarms over the test suite, essentially all of them one shape — an
/// input that states its own deleted bases (`g.1000delC`, `g.[1000G>A;1001A>C]`)
/// against an output that does not (`g.1000del`, `g.1000_1001delinsAC`), on a
/// provider holding no reference at that locus. The input converted with no
/// provider and the output could not, and nothing was wrong with either.
pub fn compare_denoted_sequences<P: ReferenceProvider + ?Sized>(
    input: &HgvsVariant,
    output: &HgvsVariant,
    provider: &P,
) -> DenotedSequenceComparison {
    use DenotedSequenceComparison as Outcome;

    if names_a_special_position(input) || names_a_special_position(output) {
        return Outcome::NotComparable(NotComparable::UnresolvableSpecialPosition);
    }
    // An allele wrapped in the predicted marker `[(…)]` states that its members
    // are *uncertain*, so "which bases does it denote" is not a question it
    // answers. `normalize` agrees and leaves such members where the caller put
    // them — `sort_cis_members_by_genomic_order` and the sibling clamps all gate
    // on cis-and-not-uncertain — so its output may legitimately still overlap.
    //
    // Checked here rather than in `variant_edit_triples_reason` on purpose: that
    // function also backs the public `apply_to_reference` and `canonical_spdi`,
    // and narrowing what *they* accept is a separate decision from what this
    // comparison is willing to adjudicate.
    if is_uncertain_allele(input) || is_uncertain_allele(output) {
        return Outcome::NotComparable(NotComparable::UncertainAllele);
    }

    let Ok((accession, input_triples)) = variant_edit_triples_reason(input, provider) else {
        return Outcome::NotComparable(NotComparable::InputDenotesNoSequence);
    };
    let (output_accession, output_triples) = match variant_edit_triples_reason(output, provider) {
        Ok(pair) => pair,
        Err(NoTriples::SelfContradictory) => return Outcome::OutputContradictsItself,
        Err(NoTriples::Untransliterable) => {
            return Outcome::NotComparable(NotComparable::OutputUntransliterable)
        }
    };
    if accession != output_accession {
        return Outcome::NotComparable(NotComparable::AccessionChanged);
    }

    let (mut start, mut end) = (u64::MAX, u64::MIN);
    for triple in input_triples.iter().chain(&output_triples) {
        start = start.min(triple.position);
        // Checked for the same reason `apply_to_reference_padded` checks it: a
        // wrap would silently shrink the window and compare the wrong bases.
        let Some(triple_end) = triple.position.checked_add(triple.deletion.len() as u64) else {
            return Outcome::NotComparable(NotComparable::WindowTooWide);
        };
        end = end.max(triple_end);
    }
    if start > end || end - start > MAX_APPLY_WINDOW {
        return Outcome::NotComparable(NotComparable::WindowTooWide);
    }

    let Some(reference) = fetch_window(provider, &accession, start, end) else {
        return Outcome::NotComparable(NotComparable::ReferenceUnreadable);
    };
    let from_input = match splice_denoted_sequence(&reference, start, &input_triples) {
        Ok(bases) => bases,
        Err(_) => return Outcome::NotComparable(NotComparable::InputDenotesNoSequence),
    };
    let from_output = match splice_denoted_sequence(&reference, start, &output_triples) {
        Ok(bases) => bases,
        // Only one of the two refusals is the description's own fault.
        // Overlapping members claim the same base whatever the reference holds;
        // a stated base that disagrees with the reference is a claim this
        // comparison cannot adjudicate — and is exactly what an input carrying a
        // `REFSEQ_MISMATCH` looks like.
        Err(SpliceFailure::Overlapping) => return Outcome::OutputContradictsItself,
        Err(SpliceFailure::StatedBasesMismatch) => {
            return Outcome::NotComparable(NotComparable::OutputUntransliterable)
        }
    };

    if same_bases(&from_input, &from_output) {
        Outcome::Agree
    } else {
        Outcome::Differ {
            accession,
            start,
            reference,
            from_input,
            from_output,
        }
    }
}

/// Why [`splice_denoted_sequence`] refused a triple set.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SpliceFailure {
    /// Two members claim the same reference base, so the result depends on an
    /// order the description does not state. A fault in the description itself.
    Overlapping,
    /// A member's stated deleted bases disagree with the reference.
    StatedBasesMismatch,
}

/// Splice `triples` into `reference` — the bases beginning at interbase
/// `win_start` — and return the resulting sequence.
///
/// Deliberately **not** [`apply_triples`] — but **no longer for the reason this
/// comment used to give**, which #1831 falsified and which is kept below only as
/// the measurement the tie-break was derived from.
///
/// **What changed (#1831).** [`apply_triples`] now sorts longer deletions first
/// among triples sharing a position — this walk's own key — so
/// [`triples_are_disjoint`] sees correctly-ordered input, no longer reports a
/// pure insertion flush against the 5' end of a deletion as an overlap, and no
/// longer decides that shape by the order the author wrote the members in. The
/// two walks now *agree* there.
///
/// **The measurement, kept because it is the argument for the tie-break.** That
/// combination is well defined — the payload lands at the junction, then the
/// span is removed — and it is a shape cis alleles reach constantly: measured
/// over one suite run, reading it as an overlap produced **233** false alarms,
/// nearly all of them `g.[…;261_262insA;262_264A[5]]`-shaped. The 233 is the
/// size of that class when it was diagnosed and is kept as that.
///
/// **Why this walk stays separate anyway**, since the historical reason no
/// longer carries it: it answers a question [`apply_triples`] cannot, returning
/// a typed [`SpliceFailure`] that separates an overlap from a stated-bases
/// mismatch. [`compare_denoted_sequences`] routes those to opposite verdicts —
/// an overlap is the output's own fault and fires, while a stated base
/// disagreeing with the reference is unadjudicable and is what a
/// `REFSEQ_MISMATCH` input looks like — and [`apply_triples`] collapses both
/// into `None`. Independently, [`triples_are_disjoint`] is depended on *in the
/// other direction* by pinned tests, so it is not a predicate to retune
/// casually.
///
/// The walk is `tests/it/common/cis_apply_oracle.rs`'s, the applier every
/// sibling-crossing test already rests on: 3'→5' so an applied splice never
/// moves a later one's coordinates, with **longer deletions first** among
/// members at one position so a span-claiming member is always applied before
/// the zero-length one flush against it. Coincident insertions are refused
/// upstream, by [`variant_edit_triples_reason`].
fn splice_denoted_sequence(
    reference: &str,
    win_start: u64,
    triples: &[SpdiVariant],
) -> Result<String, SpliceFailure> {
    let reference = reference.as_bytes();
    let mut ordered: Vec<&SpdiVariant> = triples.iter().collect();
    ordered.sort_by_key(|t| {
        (
            std::cmp::Reverse(t.position),
            std::cmp::Reverse(t.deletion.len()),
        )
    });

    let mut edited = reference.to_vec();
    let mut claimed_from = reference.len();
    for triple in ordered {
        // The caller builds the window from these very triples, so an
        // out-of-window position is unreachable; treat it defensively as a
        // stated-bases problem rather than panicking on the slice.
        let Some(start) = triple
            .position
            .checked_sub(win_start)
            .and_then(|offset| usize::try_from(offset).ok())
        else {
            return Err(SpliceFailure::StatedBasesMismatch);
        };
        let Some(end) = start.checked_add(triple.deletion.len()) else {
            return Err(SpliceFailure::StatedBasesMismatch);
        };
        if end > reference.len() {
            return Err(SpliceFailure::StatedBasesMismatch);
        }
        if end > claimed_from {
            return Err(SpliceFailure::Overlapping);
        }
        if !same_bases_bytes(&reference[start..end], triple.deletion.as_bytes()) {
            return Err(SpliceFailure::StatedBasesMismatch);
        }
        // The splice targets the mutated buffer, whose length no longer matches
        // the reference once a length-changing edit has been applied. The
        // descending walk and the overlap check above already make
        // `end <= edited.len()` hold — every applied splice sits at or past
        // `claimed_from`, so the prefix this indexes into is untouched — but bound
        // it explicitly anyway, so a future change cannot turn a logic slip back
        // into the out-of-bounds panic of #1244.
        if end > edited.len() {
            return Err(SpliceFailure::StatedBasesMismatch);
        }
        edited.splice(start..end, triple.insertion.bytes());
        // Unconditionally, including for a pure insertion — an insertion
        // *interior* to a member 5' of it is an overlap even though it claims no
        // base of its own, and that is precisely the overlap-conflicting allele
        // (`g.[2_3del;2_3insAA]`) this repository declines to canonicalize. The
        // flush case the tie-break above exists for is already safe: the
        // span-claiming member is applied first, so the zero-length one sits
        // exactly *at* `claimed_from` rather than past it.
        claimed_from = start;
    }
    String::from_utf8(edited).map_err(|_| SpliceFailure::StatedBasesMismatch)
}

/// The base `b` is compared as, folding case and the RNA alphabet.
///
/// Case, for the reason [`trim_common_flanks`] gives: reference FASTAs are often
/// soft-masked and case carries no biological meaning. `U` to `T` because the
/// two sides of a comparison need not agree on the alphabet — an `r.` payload is
/// transliterated to RNA by `hgvs_to_spdi` while the reference window it is
/// spliced into is served as DNA, so `r.6_8dupugc` and `r.6_8dup` denote one
/// sequence and would otherwise read as `UGC` against `TGC`.
fn canonical_base(b: u8) -> u8 {
    match b.to_ascii_uppercase() {
        b'U' => b'T',
        other => other,
    }
}

/// Whether two base strings denote the same sequence under [`canonical_base`].
fn same_bases(left: &str, right: &str) -> bool {
    same_bases_bytes(left.as_bytes(), right.as_bytes())
}

fn same_bases_bytes(left: &[u8], right: &[u8]) -> bool {
    left.len() == right.len()
        && left
            .iter()
            .zip(right)
            .all(|(l, r)| canonical_base(*l) == canonical_base(*r))
}

/// Read `[start, end)` from `accession`, or `None` if the provider cannot serve
/// exactly that many bases.
///
/// Tries the genomic accessor first and falls back to the generic one, so a
/// contig and a transcript are both reachable.
pub(crate) fn fetch_window<P: ReferenceProvider + ?Sized>(
    provider: &P,
    accession: &str,
    start: u64,
    end: u64,
) -> Option<String> {
    let bases = provider
        .get_genomic_sequence(accession, start, end)
        .or_else(|_| provider.get_sequence(accession, start, end))
        .ok()?;
    if bases.len() as u64 != end - start {
        return None;
    }
    Some(bases)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::hgvs::parser::parse_hgvs;
    use crate::reference::MockProvider;

    /// A 40-base contig whose bases are known, so the expected triples can be
    /// written by hand rather than read back out of the code under test.
    ///
    ///  1-based: 1234567890...
    ///           GGATTACAGGCATTAGCCTGAGGATTACAGGCATTAGCCT
    fn provider() -> MockProvider {
        let mut provider = MockProvider::new();
        provider.add_genomic_sequence("NC_KEY.1", "GGATTACAGGCATTAGCCTGAGGATTACAGGCATTAGCCT");
        // A second contig, so "members on different accessions" can be tested as
        // the real thing rather than as an absent-reference failure.
        provider.add_genomic_sequence("NC_OTHER.1", "TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT");
        provider
    }

    fn key(descriptor: &str) -> SpdiVariant {
        let variant = parse_hgvs(descriptor).expect("fixture must parse");
        canonical_spdi(&variant, &provider())
            .unwrap_or_else(|e| panic!("`{descriptor}` must canonicalize: {e}"))
    }

    /// #1159's whole point: a spanning `delins` and its decomposed cis allele are
    /// the same edit, and must produce the same key.
    ///
    /// `hgvs_to_spdi` cannot do this — it transliterates the partitioning, so it
    /// answers with one triple for the first and four for the second. The key is
    /// derived from the resulting bases, where partitioning is not represented.
    #[test]
    fn a_spanning_delins_and_its_decomposition_share_one_key() {
        // Reference 3..=7 is `ATTAC`; replace it with `GGCTA` piecewise. Every
        // one of the five bases changes, so the decomposition is five members —
        // A>G, T>G, T>C, A>T, C>A.
        let spanning = key("NC_KEY.1:g.3_7delinsGGCTA");
        let decomposed = key("NC_KEY.1:g.[3A>G;4T>G;5T>C;6A>T;7C>A]");
        assert_eq!(
            spanning, decomposed,
            "the same edit written two ways must give one key"
        );
        // And the key really is the minimal changed block, not the input's span.
        assert_eq!(spanning.sequence, "NC_KEY.1");
        assert_eq!(
            (spanning.deletion.as_str(), spanning.insertion.as_str()),
            ("ATTAC", "GGCTA")
        );
    }

    /// Member order must not matter, since a cis allele is a set of edits on one
    /// molecule.
    ///
    /// Note what this test alone cannot show: it uses two substitutions at 3 and
    /// 7, which are disjoint whichever order they are applied in, so
    /// `apply_triples` sorts them to one sequence and the assertion holds even
    /// for an implementation that keyed off application order. The case that
    /// discriminates is two insertions at one interbase, and it is
    /// `coincident_insertions_are_declined_rather_than_ordered` below.
    #[test]
    fn member_order_does_not_change_the_key() {
        assert_eq!(key("NC_KEY.1:g.[3A>G;7C>A]"), key("NC_KEY.1:g.[7C>A;3A>G]"));
    }

    /// The pad doubles until it contains the roll, and a tract that outruns the
    /// cap is declined rather than answered over a truncated window.
    ///
    /// Both halves need a tract longer than `FIRST_PAD`, so this builds its own
    /// contig rather than using the 40-base fixture: 300 `A`s, which forces at
    /// least one doubling (64 -> 128 -> 256 -> 512), and a tract past
    /// `MAX_SHIFT_TRACT`, which must decline.
    ///
    /// Without the doubling the first pad would silently cap the roll and the
    /// two spellings would key apart — the same defect one order of magnitude
    /// further out, which is exactly the failure a single fixed pad would hide.
    #[test]
    fn the_pad_grows_until_it_contains_the_roll_then_declines() {
        let homopolymer = |run: usize| {
            let mut provider = MockProvider::new();
            provider.add_genomic_sequence("NC_RUN.1", format!("C{}C", "A".repeat(run)));
            provider
        };

        // A 300-base `A` run at 2..=301. Deleting any one `A` leaves the same
        // sequence, so every spelling must key identically — which needs a
        // window wider than one `FIRST_PAD`.
        let provider = homopolymer(300);
        let key_of = |descriptor: &str| {
            let variant = parse_hgvs(descriptor).expect("fixture must parse");
            canonical_spdi(&variant, &provider).expect("must canonicalize")
        };
        let first = key_of("NC_RUN.1:g.2del");
        assert_eq!(first, key_of("NC_RUN.1:g.150del"));
        assert_eq!(first, key_of("NC_RUN.1:g.301del"));
        assert_eq!(
            (first.position, first.deletion.as_str()),
            // 0-based, so the 3'-most `A` of a run occupying 1-based 2..=301.
            (300, "A"),
            "the deletion must roll to the 3' end of the run"
        );

        // Past the cap it declines, and says why rather than returning a key
        // derived from however much reference it happened to read.
        let huge = homopolymer(MAX_SHIFT_TRACT as usize + 1_000);
        let variant = parse_hgvs("NC_RUN.1:g.2del").expect("fixture must parse");
        let error = canonical_spdi(&variant, &huge)
            .expect_err("a tract past the cap has no window-independent key");
        let message = error.to_string();
        assert!(
            message.contains("repeat tract"),
            "the decline must name the tract, not read as a generic failure; got: {message}"
        );
    }

    /// Two insertions at one interbase have no order between them, so the key
    /// declines rather than inventing one.
    ///
    /// Before this guard the two spellings produced **different keys** —
    /// `5::CA` and `8::CA` — which is worse than declining: a caller comparing
    /// keys would read one variant as two. HGVS spells "insert both, in this
    /// order" as a single ordered payload (`ins[A;C]`, `general.md:79`), so a
    /// caller who means an order has a way to say it.
    ///
    /// The decline is asserted on both spellings, because a guard that fired on
    /// only one would leave exactly the asymmetry it exists to remove.
    #[test]
    fn coincident_insertions_are_declined_rather_than_ordered() {
        for descriptor in [
            "NC_KEY.1:g.[5_6insA;5_6insC]",
            "NC_KEY.1:g.[5_6insC;5_6insA]",
        ] {
            let variant = parse_hgvs(descriptor).expect("fixture must parse");
            assert!(
                canonical_spdi(&variant, &provider()).is_err(),
                "`{descriptor}` has no order between its two insertions, so no single \
                 key describes it — declining is the answer, not picking one"
            );
        }
        // The single-member spelling states its own order, so it still keys.
        //
        // Note the payload comes back **rotated**, `CA` at 8 rather than `AC` at
        // 5: the reference reads `AC` at 6-7, so inserting `AC` at 5|6 is the
        // same edit as inserting `CA` at 7|8, and the 3' roll takes it there.
        // Pinned rather than left as "some key" because a rotation is exactly
        // the kind of thing that looks like a bug to the next reader, and
        // because it is the property that makes the roll worth having.
        let ordered = key("NC_KEY.1:g.5_6insAC");
        assert_eq!((ordered.position, ordered.insertion.as_str()), (8, "CA"));
    }

    /// The same change spelled at two positions in a tract keys identically.
    ///
    /// This is what the 3' padding buys, and it is the property #1159 needs:
    /// without a window wider than the members' own span the blunt trim has no
    /// room to roll, so each spelling keys where it was written. Measured before
    /// the padding: `g.3_5del` gave `2:ATT:` and `g.4_6del` gave `3:TTA:`; `g.1del`
    /// gave `0:G:` and `g.2del` gave `1:G:`.
    ///
    /// The pairs are asserted equal *and* pinned to the 3'-most form, because
    /// equality alone is satisfied by two spellings agreeing on a wrong answer.
    #[test]
    fn one_change_spelled_two_ways_in_a_tract_keys_once() {
        // `GG` at 1-2: deleting either `G` leaves the same sequence.
        let first = key("NC_KEY.1:g.1del");
        assert_eq!(first, key("NC_KEY.1:g.2del"));
        assert_eq!((first.position, first.deletion.as_str()), (1, "G"));

        // `GG` at 9-10, away from the contig edge.
        let inner = key("NC_KEY.1:g.9del");
        assert_eq!(inner, key("NC_KEY.1:g.10del"));
        assert_eq!((inner.position, inner.deletion.as_str()), (9, "G"));

        // `ATT` at 3-5 and `TTA` at 4-6 remove the same three bases, because
        // `ref[2] == ref[5] == 'A'`.
        let rolled = key("NC_KEY.1:g.3_5del");
        assert_eq!(rolled, key("NC_KEY.1:g.4_6del"));
        assert_eq!((rolled.position, rolled.deletion.as_str()), (3, "TTA"));

        // An insertion and the `dup` that denotes it are one variant.
        let inserted = key("NC_KEY.1:g.13_14insT");
        assert_eq!(inserted, key("NC_KEY.1:g.14dup"));
        assert_eq!((inserted.position, inserted.insertion.as_str()), (14, "T"));
    }

    /// A net deletion and a net insertion round-trip through the same path, and
    /// the key trims to the block that actually changed.
    #[test]
    fn the_key_is_the_minimal_changed_block() {
        // `g.3_5del` removes three bases. The block is reported one position 3'
        // of where it was authored, as `TTA` rather than `ATT`, because
        // `ref[2] == ref[5] == 'A'` and the change therefore rolls: deleting
        // `ATT` at 2 and deleting `TTA` at 3 leave the same sequence.
        //
        // That roll is the whole point — before the window was padded 3' this
        // returned `2:ATT:` here while `g.4_6del`, the same deletion spelled one
        // base over, returned `3:TTA:`. Two keys, one variant. Which of the two
        // survives is the 3'-maximal one, per `general.md:34`, and it is the
        // form the sibling spelling already keyed to, so converging here costs
        // no shipped key that was not already ambiguous.
        let deletion = key("NC_KEY.1:g.3_5del");
        assert_eq!(deletion.deletion, "TTA");
        assert_eq!(deletion.insertion, "");
        // A pure insertion deletes nothing.
        let insertion = key("NC_KEY.1:g.5_6insCCC");
        assert_eq!(insertion.deletion, "");
        assert_eq!(insertion.insertion, "CCC");
    }

    /// Two genuinely different edits must not collide — a key that made
    /// everything equal would pass every test above.
    #[test]
    fn different_edits_get_different_keys() {
        assert_ne!(key("NC_KEY.1:g.3A>G"), key("NC_KEY.1:g.3A>C"));
        assert_ne!(key("NC_KEY.1:g.3A>G"), key("NC_KEY.1:g.4T>G"));
        assert_ne!(key("NC_KEY.1:g.3_5del"), key("NC_KEY.1:g.3_6del"));
    }

    /// `apply_to_reference` returns the window before and after, and the two
    /// differ exactly by the edit.
    #[test]
    fn apply_to_reference_returns_both_windows() {
        let variant = parse_hgvs("NC_KEY.1:g.3_7delinsGGCTA").unwrap();
        let applied = apply_to_reference(&variant, &provider()).expect("applies");
        assert_eq!(applied.accession, "NC_KEY.1");
        assert_eq!(applied.start, 2, "0-based interbase start of g.3");
        assert_eq!(applied.reference, "ATTAC");
        assert_eq!(applied.resulting, "GGCTA");
    }

    /// The shapes with no single resulting sequence must decline, not guess.
    ///
    /// Each is a distinct reason, and a caller needs the decline rather than an
    /// answer derived from one arbitrary reading of an ambiguous input. Every
    /// fixture is `expect`ed to parse, so one that stopped parsing fails loudly
    /// instead of being skipped — a skipped fixture asserts nothing.
    #[test]
    fn shapes_without_one_resulting_sequence_decline() {
        for (descriptor, why) in [
            (
                "NC_KEY.1:g.[3_5del;4T>G]",
                "overlapping members — applying them depends on order",
            ),
            (
                "NC_KEY.1:g.[3A>G(;)7C>A]",
                "trans phase — two molecules, not one sequence",
            ),
            (
                "[NC_KEY.1:g.3A>G;NC_OTHER.1:g.7T>G]",
                "members on different accessions",
            ),
        ] {
            let variant = parse_hgvs(descriptor)
                .unwrap_or_else(|e| panic!("fixture `{descriptor}` must parse: {e}"));
            assert!(
                apply_to_reference(&variant, &provider()).is_err(),
                "`{descriptor}` must decline ({why})"
            );
            assert!(
                canonical_spdi(&variant, &provider()).is_err(),
                "`{descriptor}` must decline for the key too ({why})"
            );
        }
    }

    /// An insertion **strictly interior to a pure deletion** is declined by the
    /// applier — the behavioural half of [`WriteFootprint::obstructs_splice_of`]'s
    /// junction rule (#1406, #1749).
    ///
    /// # Why this is not a row in the test above
    ///
    /// Every shape in `shapes_without_one_resulting_sequence_decline` denotes
    /// *no* single sequence, so declining it is a semantic verdict. This one is
    /// the opposite, and that is the point: `g.[10_20del;14_15insCC]` composes
    /// uniquely — the deletion removes every base it spans, so an interior
    /// junction has nothing left to be positioned against — and
    /// `normalize::overlap` deliberately does **not** report it
    /// (`insertion_interior_to_deletion_is_not_a_conflict`). It is declined
    /// here only because the 3' -> 5' walk cannot *execute* it: the zero-width
    /// triple splices first and the deletion's splice then consumes the bases
    /// it just wrote. That is exactly the distinction `obstructs_splice_of`
    /// exists to draw, so it needs a test where the two answers differ.
    ///
    /// # Why it is asserted HERE (#1749 mutation matrix)
    ///
    /// `normalize::footprint`'s `the_splice_precondition_ignores_whether_bases_survive`
    /// pins the predicate, and `shapes_without_one_resulting_sequence_decline`
    /// pins the **base-intersection** half of the caller. Between them nothing
    /// pinned that the caller still *consults* the junction half: replacing the
    /// `obstructs_splice_of` call at its only call site with a bare base
    /// intersection passed all 10,487 tests. The failure is silent rather than
    /// loud because each stated deletion is validated against the **pristine**
    /// `ref_bytes`, so the corrupted splice never trips the ref-match check —
    /// it just returns a wrong sequence.
    #[test]
    fn an_insertion_interior_to_a_deletion_is_declined_by_the_applier() {
        let descriptor = "NC_KEY.1:g.[10_20del;14_15insCC]";
        let variant = parse_hgvs(descriptor).expect("fixture must parse");
        assert!(
            apply_to_reference(&variant, &provider()).is_err(),
            "`{descriptor}`: the insertion's junction is strictly interior to \
             the deletion, so the 3' -> 5' walk cannot execute the pair in one \
             pass and the applier must decline rather than return a sequence"
        );
        assert!(
            canonical_spdi(&variant, &provider()).is_err(),
            "`{descriptor}`: the published key must decline it for the same \
             reason — a key may not be derived from a splice that cannot run"
        );
    }

    /// The discriminating control for the test above: an insertion **flush**
    /// against either edge of the same deletion still applies.
    ///
    /// Without this, the decline above would also be satisfied by a predicate
    /// that called every insertion-plus-deletion pair an obstruction — which is
    /// the over-rejection `gap < end`'s strictness exists to prevent, and the
    /// class `CLAUDE.md` records as 233 false fires of the denoted-sequence
    /// oracle.
    #[test]
    fn an_insertion_flush_against_that_deletion_still_applies() {
        for descriptor in [
            "NC_KEY.1:g.[10_20del;20_21insCC]",
            "NC_KEY.1:g.[10_20del;9_10insCC]",
        ] {
            let variant = parse_hgvs(descriptor).expect("fixture must parse");
            assert!(
                apply_to_reference(&variant, &provider()).is_ok(),
                "`{descriptor}`: a junction flush against a deletion's edge is \
                 not interior to it, so the pair splices in one walk"
            );
        }
    }

    /// The control for the test above: a single-member variant on a served
    /// accession applies. Without this, every decline assertion would also be
    /// satisfied by an `apply_to_reference` that declined *everything*.
    #[test]
    fn a_plain_single_member_variant_applies() {
        let variant = parse_hgvs("NC_KEY.1:g.3A>G").expect("must parse");
        assert!(apply_to_reference(&variant, &provider()).is_ok());
        assert!(canonical_spdi(&variant, &provider()).is_ok());
    }

    /// An unknown accession declines rather than panicking.
    #[test]
    fn an_unreadable_reference_declines() {
        let variant = parse_hgvs("NC_ABSENT.1:g.3A>G").unwrap();
        assert!(apply_to_reference(&variant, &provider()).is_err());
    }

    /// The 40-base fixture served as a transcript, so the `r.` axis is reachable;
    /// `spell` chooses the DNA or the uracil spelling of the same molecule.
    fn transcript_provider(spell_uracil: bool) -> MockProvider {
        use crate::reference::transcript::{Exon, GenomeBuild, ManeStatus, Strand, Transcript};

        const DNA: &str = "GGATTACAGGCATTAGCCTGAGGATTACAGGCATTAGCCT";
        let sequence = if spell_uracil {
            DNA.replace('T', "U")
        } else {
            DNA.to_string()
        };
        let length = sequence.len() as u64;
        let mut provider = MockProvider::new();
        provider.add_transcript(Transcript::new(
            "NR_KEY.1".to_string(),
            Some("SYNTH".to_string()),
            Strand::Plus,
            sequence.clone(),
            None,
            None,
            vec![Exon::with_genomic(1, 1, length, 1, length)],
            Some("chr_key".to_string()),
            Some(1),
            Some(length),
            GenomeBuild::GRCh38,
            ManeStatus::None,
            None,
            None,
        ));
        provider.add_genomic_sequence("chr_key", sequence);
        provider
    }

    /// The alphabet fold, in both directions: what it reconciles, and the
    /// boundary it does not reach.
    ///
    /// **Case is reconciled unconditionally.** Real genomic FASTAs lowercase
    /// repeat tracts, so the same accession can be served soft-masked by one
    /// provider and uppercase by another; case carries no biological meaning, so
    /// the key must not move. Every shape whose key can carry reference-derived
    /// bases is covered — a stated deletion, an unspelled one, a `dup` and an
    /// `inv` (both of which read their inserted bases *out of* the reference),
    /// and a `delins` mixing both directions.
    ///
    /// **`U`/`T` is reconciled only part of the way, and that is the point of
    /// asserting it.** [`key_alphabet`] folds *after* the two comparisons that
    /// decide the emitted block, and both are `eq_ignore_ascii_case`, which does
    /// not relate `U` to `T`. So a uracil-spelled provider agrees with a
    /// DNA-spelled one exactly when the U-bearing bases reach the block through
    /// [`trim_common_flanks`]; where a `U` sits in a **stated** deletion
    /// [`apply_triples`] reads a ref-mismatch and declines, and where it stops an
    /// insertion's common-prefix scan the roll is cut short and the position
    /// moves. Both rows are pinned as the current answer rather than left to be
    /// rediscovered as a surprise — a uracil-spelled transcript provider is not a
    /// RefSeq spelling, so neither is a defect on any supported path, but a doc
    /// claiming blanket reconciliation would be wrong and this is what keeps it
    /// honest.
    #[test]
    fn the_alphabet_fold_reconciles_case_and_bounds_the_uracil_case() {
        for descriptor in [
            "NC_KEY.1:g.3_7delinsGGCTA",
            "NC_KEY.1:g.3_5del",
            "NC_KEY.1:g.3_5dup",
            "NC_KEY.1:g.3_7inv",
            "NC_KEY.1:g.3A>G",
            "NC_KEY.1:g.5_6insAC",
        ] {
            let variant = parse_hgvs(descriptor).expect("fixture must parse");
            let mut masked = MockProvider::new();
            masked.add_genomic_sequence(
                "NC_KEY.1",
                "GGATTACAGGCATTAGCCTGAGGATTACAGGCATTAGCCT".to_ascii_lowercase(),
            );
            let upper =
                canonical_spdi(&variant, &provider()).expect("the uppercase reference keys");
            let lower = canonical_spdi(&variant, &masked).expect("the soft-masked reference keys");
            assert_eq!(
                upper, lower,
                "`{descriptor}` must key identically on a soft-masked reference"
            );
            assert!(
                upper.deletion.chars().all(|c| !c.is_ascii_lowercase())
                    && upper.insertion.chars().all(|c| !c.is_ascii_lowercase()),
                "`{descriptor}` emitted lowercase bases: {upper}"
            );
        }

        let dna = transcript_provider(false);
        let uracil = transcript_provider(true);
        let keyed = |descriptor: &str, provider: &MockProvider| {
            let variant = parse_hgvs(descriptor).expect("fixture must parse");
            canonical_spdi(&variant, provider)
                .ok()
                .map(|spdi| spdi.to_string())
        };

        // Reconciled: the U-bearing bases reach the block through the trim.
        for descriptor in [
            "NR_KEY.1:r.[3a>g;7c>a]",
            "NR_KEY.1:r.3a>g",
            "NR_KEY.1:r.14dup",
        ] {
            assert_eq!(
                keyed(descriptor, &dna),
                keyed(descriptor, &uracil),
                "`{descriptor}` is a shape the fold does reconcile"
            );
        }
        assert_eq!(
            keyed("NR_KEY.1:r.[3a>g;7c>a]", &uracil).as_deref(),
            Some("NR_KEY.1:2:ATTAC:GTTAA"),
            "and it reconciles by folding, not by refusing both sides"
        );

        // Not reconciled: a stated deletion is validated before the fold.
        for descriptor in [
            "NR_KEY.1:r.3_5del",
            "NR_KEY.1:r.3_7delinsggcua",
            "NR_KEY.1:r.3_7inv",
        ] {
            assert!(
                keyed(descriptor, &dna).is_some(),
                "`{descriptor}` keys on the RefSeq spelling"
            );
            assert_eq!(
                keyed(descriptor, &uracil),
                None,
                "`{descriptor}` is declined on a uracil provider — `apply_triples` \
                 validates the stated deletion case-insensitively, not alphabet-insensitively"
            );
        }

        // Not reconciled: the roll's common-prefix scan stops at the first U/T
        // disagreement, so the position moves rather than the bases.
        assert_eq!(
            (
                keyed("NR_KEY.1:r.13_14insu", &dna).as_deref(),
                keyed("NR_KEY.1:r.13_14insu", &uracil).as_deref()
            ),
            (Some("NR_KEY.1:14::T"), Some("NR_KEY.1:13::T")),
            "the 3' roll is cut short on a uracil provider"
        );
    }

    /// A cis allele mixing an `r.` member with an `n.` one on a single accession
    /// is reachable, so [`key_alphabet`]'s `any`-versus-`all` choice is a real
    /// decision and not a formality.
    ///
    /// The comment on that arm used to say the members "must share one accession
    /// to key at all, so in practice they share one axis". They need not: the
    /// description below parses, is one cis allele on one accession, and keys.
    /// With `any` the whole key is folded and agrees with the DNA-spelled
    /// provider; with `all` it would emit `2:AUUAC:GUUAA`, one key carrying two
    /// alphabets, which is exactly what the fold exists to prevent. The `n.`-only
    /// spelling is asserted alongside as the control that the `r.` member is what
    /// selects the fold.
    #[test]
    fn a_mixed_axis_cis_allele_folds_on_any_rna_member() {
        let uracil = transcript_provider(true);
        let keyed = |descriptor: &str| {
            let variant = parse_hgvs(descriptor).expect("fixture must parse");
            canonical_spdi(&variant, &uracil)
                .unwrap_or_else(|e| panic!("`{descriptor}` must key: {e}"))
                .to_string()
        };
        assert_eq!(
            keyed("[NR_KEY.1:r.3a>g;NR_KEY.1:n.7C>A]"),
            "NR_KEY.1:2:ATTAC:GTTAA",
            "one `r.` member folds the whole key"
        );
        assert_eq!(
            keyed("NR_KEY.1:n.[3A>G;7C>A]"),
            "NR_KEY.1:2:AUUAC:GUUAA",
            "the same allele with no `r.` member is not folded — the control that \
             the `r.` member is what selects `AlphabetMode::Rna`"
        );
    }
    /// The flush rule is geometric, not kind-specific: an insertion at the 5'
    /// edge of a **deletion** or an **inversion** composes as cleanly as the one
    /// against a substitution pinned in `issue_1831_applier_member_order`, because the edit 3' of the junction is
    /// applied first and the zero-width insertion then lands exactly at the
    /// span's start rather than inside it.
    ///
    /// Bases 11-13 of the fixture are `CAT`. `g.11_13del` removes them, so the
    /// window `CAT` becomes just the inserted `AC`; `g.11_13inv` reverses `CAT`
    /// to `ATG`, so the window becomes `AC` + `ATG`. Both spellings of each
    /// allele give one sequence — the tie-break sorts by deletion width, not
    /// input order — so this asserts every member ordering, the property that
    /// would regress first if the sort key changed.
    #[test]
    fn an_insertion_flush_against_a_deletion_or_inversion_applies() {
        for (descriptor, resulting) in [
            ("NC_KEY.1:g.[10_11insAC;11_13del]", "AC"),
            ("NC_KEY.1:g.[11_13del;10_11insAC]", "AC"),
            ("NC_KEY.1:g.[10_11insAC;11_13inv]", "ACATG"),
            ("NC_KEY.1:g.[11_13inv;10_11insAC]", "ACATG"),
        ] {
            let variant = parse_hgvs(descriptor).expect("fixture must parse");
            let applied = apply_to_reference(&variant, &provider())
                .unwrap_or_else(|e| panic!("`{descriptor}` must apply: {e}"));
            assert_eq!(applied.start, 10, "0-based interbase start of base 11");
            assert_eq!(applied.reference, "CAT", "bases 11-13 of the fixture");
            assert_eq!(
                applied.resulting, resulting,
                "`{descriptor}`: the insertion lands 5' of the span, which then \
                 rewrites its own bases"
            );
        }
    }

    /// The interior rule holds for an inversion just as it does for a deletion:
    /// an insertion whose junction falls *inside* the reversed span has no
    /// defined position and is declined.
    ///
    /// `g.11_12insAC` sits at the junction 11|12, interior to `g.10_13inv`
    /// (bases 10-13), so the pair denotes no single sequence.
    #[test]
    fn an_insertion_interior_to_an_inversion_declines() {
        let variant = parse_hgvs("NC_KEY.1:g.[10_13inv;11_12insAC]").expect("must parse");
        assert!(
            apply_to_reference(&variant, &provider()).is_err(),
            "an insertion interior to an inversion has no single resulting sequence"
        );
    }

    // --- #1749: one overlap definition, on write footprints -----------------

    fn triple(position: u64, deletion: &str, insertion: &str) -> SpdiVariant {
        SpdiVariant {
            sequence: "NC_TEST.1".to_string(),
            position,
            deletion: deletion.to_string(),
            insertion: insertion.to_string(),
        }
    }

    /// `apply_triples` sorts by descending position with a **stable** sort, so a
    /// zero-width triple and a span triple sharing one interbase position keep
    /// their input order — and the running-`reach` test then reads them
    /// differently depending on which it visits first.
    ///
    /// An insertion flush against the 5' edge of a deletion is well defined
    /// (`CLAUDE.md` records it as a 233-fire false-positive class of the
    /// denoted-sequence oracle), so the verdict must be "disjoint" in **both**
    /// member orders. Today it is not, and nothing in the description picks
    /// between them.
    #[test]
    fn a_flush_insertion_is_disjoint_in_either_member_order() {
        let deletion = triple(4, "TTTTT", "");
        let insertion = triple(4, "", "A");

        for (label, triples) in [
            ("deletion first", vec![deletion.clone(), insertion.clone()]),
            ("insertion first", vec![insertion.clone(), deletion.clone()]),
        ] {
            let mut ordered: Vec<&SpdiVariant> = triples.iter().collect();
            ordered.sort_by_key(|t| std::cmp::Reverse(t.position));
            assert!(
                triples_are_disjoint(&ordered),
                "{label}: an insertion at the 5' edge of a deletion claims no \
                 base, so it is disjoint from it whichever order the members \
                 were written in",
            );
        }
    }

    /// The same order independence, stated as a property over every pair of a
    /// zero-width triple and a span triple that share a position.
    #[test]
    fn disjointness_does_not_depend_on_member_order() {
        let cases = [
            (triple(4, "TTTTT", ""), triple(4, "", "A")),
            (triple(10, "AC", "G"), triple(10, "", "TT")),
            (triple(7, "G", ""), triple(7, "", "C")),
        ];
        for (span, zero_width) in cases {
            let forward = {
                let v = [span.clone(), zero_width.clone()];
                let mut o: Vec<&SpdiVariant> = v.iter().collect();
                o.sort_by_key(|t| std::cmp::Reverse(t.position));
                triples_are_disjoint(&o)
            };
            let reverse = {
                let v = [zero_width.clone(), span.clone()];
                let mut o: Vec<&SpdiVariant> = v.iter().collect();
                o.sort_by_key(|t| std::cmp::Reverse(t.position));
                triples_are_disjoint(&o)
            };
            assert_eq!(
                forward, reverse,
                "disjointness of {span:?} and {zero_width:?} changed with member \
                 order: {forward} vs {reverse}",
            );
        }
    }

    // -----------------------------------------------------------------------
    // #2104: the decline classification, tested over the enum.
    //
    // These construct each `ApplyDecline` directly rather than driving an input
    // through the applier, because no input is known that reaches
    // `MembersOverlap` through `EquivalenceChecker::compare_triples` and
    // `ContractViolated` is unreachable from either caller by construction —
    // measured, see the enum's own docs. A pipeline test would therefore pin
    // nothing, which is exactly what #2104 found.
    // -----------------------------------------------------------------------

    /// `ALL` cannot silently fall behind the enum.
    ///
    /// A new variant is a compile error in `precedence`; this then fails until
    /// `ALL` carries it too, because the ranks of `ALL`'s members must be
    /// exactly `0..ALL.len()`. That also pins the ranks as *distinct*, which is
    /// what makes `governing` deterministic.
    #[test]
    fn all_declines_are_listed_exactly_once() {
        let mut ranks: Vec<u8> = ApplyDecline::ALL.iter().map(|d| d.precedence()).collect();
        ranks.sort_unstable();
        let expected: Vec<u8> = (0..ApplyDecline::ALL.len() as u8).collect();
        assert_eq!(
            ranks,
            expected,
            "ApplyDecline::ALL must hold every variant exactly once, with distinct \
             precedences; got {ranks:?} for {:?}",
            ApplyDecline::ALL,
        );
    }

    /// The rule #2075's PR body states in words — a reference obstacle governs
    /// an overlap, because a difference cannot be asserted against a sequence
    /// that could not be built.
    ///
    /// Asserted in **both** argument orders. Reversing this used to be a
    /// mutation the whole suite could not see (#2104).
    #[test]
    fn a_reference_mismatch_governs_a_member_overlap() {
        assert_eq!(
            ApplyDecline::ReferenceMismatch.governing(ApplyDecline::MembersOverlap),
            ApplyDecline::ReferenceMismatch,
        );
        assert_eq!(
            ApplyDecline::MembersOverlap.governing(ApplyDecline::ReferenceMismatch),
            ApplyDecline::ReferenceMismatch,
        );
    }

    /// A broken precondition of the applier governs everything, in both orders,
    /// so an internal inconsistency can never be masked by a decline about the
    /// descriptions.
    #[test]
    fn a_contract_violation_governs_every_other_decline() {
        for other in ApplyDecline::ALL {
            assert_eq!(
                ApplyDecline::ContractViolated.governing(other),
                ApplyDecline::ContractViolated,
                "ContractViolated must govern {other:?}",
            );
            assert_eq!(
                other.governing(ApplyDecline::ContractViolated),
                ApplyDecline::ContractViolated,
                "ContractViolated must govern {other:?} from the other side too",
            );
        }
    }

    /// `governing` is symmetric and idempotent over the whole enum — the two
    /// properties that make "which decline wins" independent of which side of a
    /// comparison declined.
    #[test]
    fn governing_is_symmetric_and_idempotent() {
        for first in ApplyDecline::ALL {
            assert_eq!(first.governing(first), first, "{first:?} is not idempotent");
            for second in ApplyDecline::ALL {
                assert_eq!(
                    first.governing(second),
                    second.governing(first),
                    "governing is not symmetric for {first:?} and {second:?}",
                );
            }
        }
    }
}