bixverse-rs 0.6.4

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

use ann_search_rs::utils::dist::Dist;
#[cfg(feature = "single-cell")]
use std::io;
use thiserror::Error;

use crate::prelude::*;

/// All error variants that can occur across bixverse operations.
///
/// Errors are grouped by subsystem: faer-backed linear algebra, binary file
/// I/O for the single cell store, HDF5/h5ad ingestion, MTX ingestion, shared
/// format parsing and other errors.
#[derive(Debug, Error)]
pub enum BixverseErrors {
    // -- arguments --
    /// General error for invalid arguments to a given function
    #[error("Invalid argument: {0}")]
    InvalidArgument(String),

    /// Error for wrong integer parameters
    #[error("Parameter {0} must be a positive non-zero integer.")]
    MustBePositive(String),

    // -- extendr --
    /// List to HashMap parsing error occured
    #[error("R list parsing failed: {0}")]
    RListParse(&'static str),

    // -- Math / Faer --
    /// SVD from faer failed to converge or returned no solution.
    ///
    /// Typically caused by ill-conditioned or degenerate input (e.g. all-zero
    /// rows, NaNs, rank-deficient matrices beyond the requested rank).
    #[error("The faer SVD failed: {0}")]
    FaerSvdError(String),

    /// Eigen decomposition from faer failed.
    ///
    /// Usually indicates a non-symmetric matrix being passed to a symmetric
    /// solver, or numerical breakdown on degenerate input.
    #[error("The faer Eigen decomposition failed - please verify the data")]
    FaerEigenError,

    /// Cholesky decomposition from faer failed.
    #[error("The faer Cholesky failed: {0}")]
    FaerCholeskyError(#[from] faer::linalg::solvers::LltError),

    // -- PCA with missing values --
    /// A row or column of the input to PPCA / BPCA holds no observed value.
    #[error("PCA with missing values: {axis} {index} is entirely missing; remove it first.")]
    PcaAllMissing {
        /// `"row"` or `"column"`
        axis: &'static str,
        /// Zero-based index of the offending row or column
        index: usize,
    },

    /// More components were requested than the data can hold.
    #[error("PCA: requested {n_pcs} components, but at most {max} are possible.")]
    PcaTooManyComponents {
        /// Requested number of components
        n_pcs: usize,
        /// `min(nrow, ncol)`
        max: usize,
    },

    // -- Kernel smoothing --
    /// A kernel bandwidth was requested for fewer than two points.
    #[error("Kernel bandwidth needs at least 2 data points, got {found}.")]
    BandwidthTooFewPoints {
        /// Number of points supplied
        found: usize,
    },

    /// A Sheather-Jones pilot functional came back non-finite or non-positive.
    ///
    /// R raises "sample is too sparse to find TD" / "... alph2" here. It means
    /// the pair-distance counts carry too little signal for the pilot estimate,
    /// which in practice is a degenerate or near-constant sample.
    #[error("Sheather-Jones bandwidth: sample too sparse to find {stage}.")]
    BandwidthTooSparse {
        /// Which pilot functional failed, `TD` or `alph2`
        stage: &'static str,
    },

    /// The Sheather-Jones bracket search never enclosed a root.
    #[error("Sheather-Jones bandwidth: no solution in the search interval.")]
    BandwidthNoBracket,

    /// Two vectors that had to agree in length did not.
    #[error("Length mismatch for '{name}': expected {expected}, got {found}.")]
    LengthMismatch {
        /// Name of the offending input
        name: &'static str,
        /// Length that was required
        expected: usize,
        /// Length that was supplied
        found: usize,
    },

    // -- Gaussian processes --
    /// A Gaussian process was handed empty training data.
    #[error("Gaussian process: {name} is empty")]
    GpEmptyInput {
        /// Which input was empty
        name: &'static str,
    },

    /// The training inputs and responses disagree in length.
    #[error(
        "Gaussian process: {n_x} training inputs against {n_y} response rows; the two must be aligned"
    )]
    GpDimensionMismatch {
        /// Length of the input vector
        n_x: usize,
        /// Rows in the response matrix
        n_y: usize,
    },

    /// A hyperparameter is outside its supported range.
    #[error("Gaussian process: hyperparameter '{name}' is {value}, which is not supported")]
    GpInvalidHyperparameter {
        /// Name of the offending hyperparameter
        name: &'static str,
        /// The value that was rejected
        value: f64,
    },

    /// A non-finite value reached the fit.
    ///
    /// Checked up front because a `NaN` otherwise surfaces as an unexplained
    /// Cholesky failure much later, with nothing pointing back at the input.
    #[error("Gaussian process: {name} contains a non-finite value")]
    GpNonFiniteInput {
        /// Which input carried the non-finite value
        name: &'static str,
    },

    /// The landmark Gram matrix stayed singular through the whole jitter ladder.
    #[error(
        "Gaussian process: the {n_landmarks} x {n_landmarks} landmark Gram is not positive definite even at a jitter of {jitter}; spread the landmarks out or shorten the length scale"
    )]
    GpLandmarkCholeskyFailed {
        /// Largest jitter that was tried
        jitter: f64,
        /// Number of landmarks
        n_landmarks: usize,
    },

    /// The posterior Cholesky failed.
    ///
    /// Distinct from [BixverseErrors::GpLandmarkCholeskyFailed] because this
    /// factorises `A A^T / sigma^2 + I`, whose smallest eigenvalue is at least
    /// one in exact arithmetic. A failure here means a non-finite value got
    /// through the input checks, not that the problem is ill-conditioned.
    #[error(
        "Gaussian process: the posterior Cholesky failed, which should be impossible for a matrix with unit diagonal dominance; the accumulation produced non-finite entries"
    )]
    GpPosteriorCholeskyFailed,

    /// Every landmark sits at the same coordinate.
    #[error(
        "Gaussian process: all landmarks share the same coordinate, so the kernel matrix is degenerate"
    )]
    GpDegenerateLandmarks,

    /// Every sample weight is zero.
    ///
    /// A zero weight drops its observation, so this leaves the fit with no data
    /// and returns the prior everywhere. Indistinguishable from a genuinely
    /// flat response, hence an error rather than a silent zero curve.
    #[error("Gaussian process: every sample weight is zero, so the fit would carry no data")]
    GpAllWeightsZero,

    // -- statrs --
    /// Beta distribution construction failed
    #[error("Beta distribution error: {0}")]
    BetaDistribution(String),

    // -- ann-search-rs --
    /// Propagate errors from the ann-search-rs crate
    #[error("Error from the ann-search-rs crate: {0}")]
    AnnSearchRsError(#[from] ann_search_rs::errors::AnnSearchErrors),

    // -- edge-rs --
    /// Propagate errors from the edge-rs crate
    #[cfg(feature = "dge")]
    #[error("Error from the edge-rs crate: {0}")]
    EdgeRsError(#[from] edge_rs::errors::EdgeErrors),

    // -- bonsai-rs --
    /// Propagate errors from the bonsai-rs crate
    #[cfg(feature = "bonsai")]
    #[error("Error from the bonsai-rs crate: {0}")]
    BonsaiRsError(#[from] bonsai_rs::errors::BonsaiErrors),

    /// Propagate errors from the sanity-sc-rs crate, as re-exported by
    /// bonsai-rs
    #[cfg(feature = "bonsai")]
    #[error("Error from the sanity-sc-rs crate: {0}")]
    SanityRsError(#[from] bonsai_rs::sanity_sc_rs::errors::SanityErrors),

    /// More than one coefficient was handed to the limma route.
    ///
    /// `topTable` tabulates a single column. Testing several coefficients at
    /// once is the moderated F question, which needs `top_table_f` rather than
    /// this chain.
    #[cfg(feature = "dge")]
    #[error(
        "limma tests a single coefficient or contrast at a time; {n_coef} coefficients were given"
    )]
    LimmaMultiCoef {
        /// Number of coefficients the caller asked to test together
        n_coef: usize,
    },

    /// An input vector does not line up with the observations being modelled.
    ///
    /// Covers the design rows, the subject labels and the offsets, all of which
    /// have to be as long as the cell or sample selection.
    #[cfg(feature = "dge")]
    #[error("Differential expression: {name} has {got} entries but {expected} were expected")]
    DgeShapeMismatch {
        /// Which input is the wrong length
        name: &'static str,
        /// What the selection implies
        expected: usize,
        /// What was handed over
        got: usize,
    },

    // -- distances --
    /// Distance type not supported
    #[error("Distance metric '{0}' is not supported for this method.")]
    DistanceNotSupported(Dist),

    // -- graph based errors ---
    /// Error for algorithms that expect undirected graphs
    ///
    /// For methods that expect an undirected graph, but received a directed
    /// one.
    #[error("The Graph is directed but needs to be undirected for this algorithm.")]
    GraphDirectedError,

    /// A node index in an edge list points outside the graph
    #[error("Graph: {name} index {index} is out of range for {n_nodes} nodes.")]
    GraphIndexOutOfRange {
        /// Which input carried the index
        name: &'static str,
        /// The offending index
        index: usize,
        /// Number of nodes in the graph
        n_nodes: usize,
    },

    /// A per-node or per-edge input does not line up with the graph
    #[error("Graph: {name} has length {got}, but {expected} was expected.")]
    GraphLengthMismatch {
        /// Which input is the wrong length
        name: &'static str,
        /// Length the graph implies
        expected: usize,
        /// Length that was supplied
        got: usize,
    },

    /// A node type present in the graph has no type weight
    #[error("Graph: node type '{0}' has no type weight.")]
    MissingNodeTypeWeight(String),

    /// Error for community membership/graph node number mismatch
    ///
    /// In cases where the graph and the community membership do not agree.
    #[error("The number of nodes and membership assignments in the communities do not add up.")]
    CommunityAssignmentMismatch,

    /// Error if a partition label sits outside the declared partition count
    #[error("The partition label {label} is out of range for {n_partitions} partitions.")]
    PartitionLabelOutOfRange {
        /// The offending label
        label: usize,
        /// Number of declared partitions
        n_partitions: usize,
    },

    /// Most of the supplied kNN indices point outside the cell set
    ///
    /// The usual cause is a kNN computed against a reference index rather than
    /// the query set, which drops every edge and returns an all-zero
    /// connectivity matrix looking like a genuine result.
    #[cfg(feature = "single-cell")]
    #[error(
        "PAGA: {out_of_range} of {total} kNN entries point outside the {n_cells} cells; the graph was probably built against a different index"
    )]
    PagaKnnIndicesOutOfRange {
        /// Entries pointing past the last cell
        out_of_range: usize,
        /// Entries supplied in total
        total: usize,
        /// Cells in the input
        n_cells: usize,
    },

    /// Error if the partition count exceeds what the dense coarsening accepts
    #[error(
        "The number of partitions ({n_partitions}) exceeds the maximum of {max} for graph abstraction."
    )]
    TooManyPartitions {
        /// Provided number of partitions
        n_partitions: usize,
        /// The supported maximum
        max: usize,
    },

    // -- matrix algebra errors --
    /// Error if the feature dimensions are not the same
    #[error("The feature dimensions between the two matrices are unequal! x: {dim_x}, y: {dim_y}")]
    NonMatchingFeatureDim {
        /// Feature dimensions matrix x
        dim_x: usize,
        /// Feature dimensions matrix y
        dim_y: usize,
    },

    /// Label length and number of cells do not match for Harmony
    #[error(
        "The labels length ({label_length}) does not match the number of samples ({n_samples})"
    )]
    NumberLabelsNotEqualSampleNumber {
        /// Provided label length
        label_length: usize,
        /// Number of cells
        n_samples: usize,
    },

    /// If the reference is out of range
    #[error("The provided reference is out of range")]
    ReferenceOutOfRange,

    // -- sparse erros --
    /// Error in situations were data_2 in [`crate::prelude::CompressedSparseData2`]
    /// is asked for but not available
    #[error("data_2 slot is None but was requested")]
    Data2NotAvailable,

    /// Error if the slice index is out of bounds
    #[error("The slice index ({index}) is out of bounds (length: {len})")]
    SliceIndexOutOfBounds {
        /// The chosen index
        index: usize,
        /// The actual length
        len: usize,
    },

    /// Error if the slice index is duplicated
    #[error("You provided a duplicated slice index {0}")]
    DuplicateSliceIndex(usize),

    /// Error for sparse multiplication dimension mismatches
    #[error(
        "The dimensions of the matrix do not support sparse multiplication (matrix a n_col: {n_col_a}; matrix b n_row: {n_row_b})"
    )]
    SparseMatrixMultiplication {
        /// Number of columns in matrix a
        n_col_a: usize,
        /// Number of rows in matrix b
        n_row_b: usize,
    },

    /// Dimension mismatch error during construction
    #[error("The indices (len: {indices_len}) and data (len: {data_len}) need to have same length")]
    DimensionMisMatchSparse {
        /// Length indices
        indices_len: usize,
        /// Length data
        data_len: usize,
    },

    /// Error if the `indptr` of a [`crate::prelude::CompressedSparseData2`] is
    /// inconsistent with the shape it declares or with the stored values.
    ///
    /// Raised by [`crate::prelude::CompressedSparseData2::validate`] on input
    /// that crossed an FFI boundary, where the struct's public fields could
    /// have been populated with anything.
    #[error("The sparse indptr is inconsistent ({detail}): expected {expected}, got {got}")]
    SparseIndptrInvalid {
        /// Which invariant failed
        detail: &'static str,
        /// The value implied by the rest of the structure
        expected: usize,
        /// The value actually found
        got: usize,
    },

    /// Error if there is a dimension mismatch in terms of the two matrices
    #[error("The shape of the two sparse matrices must be the same.")]
    ShapeMismatchSparse,

    /// Error if the [crate::prelude::CompressedSparseData2] is not in CSR.
    #[error("The SparseCompressedData2 must be in CSR format")]
    SparseMatrixMustBeCsr,

    /// A row summed to (effectively) zero where a row-stochastic normalisation
    /// was asked for.
    ///
    /// On a kNN-derived affinity matrix every node has `k` out-edges, so a zero
    /// row means the kernel weights underflowed rather than that the graph is
    /// legitimately disconnected. Anything built on top of the operator would
    /// be meaningless, hence an error rather than leaving the row at zero.
    #[error(
        "Row {row} of the sparse matrix sums to {row_sum}, which is not positive, so it cannot be normalised to a row-stochastic form (isolated node, or the kernel weights underflowed)"
    )]
    SparseMatrixIsolatedRow {
        /// The offending row index
        row: usize,
        /// The row sum that was rejected
        row_sum: f64,
    },

    /// A CSR matrix failed one of its structural invariants.
    ///
    /// Raised by [crate::core::math::sparse::validate_square_csr]. The static
    /// string names the invariant, because "the graph is malformed" alone leaves
    /// the caller to bisect their own input.
    #[error("The CSR matrix is malformed: {0}")]
    MalformedCsr(&'static str),

    /// Error if the [crate::prelude::CompressedSparseData2] is not in Csc.
    #[error("The SparseCompressedData2 must be in CSC format")]
    SparseMatrixMustBeCsc,

    /// General error for sparse matrix format mismatches
    #[error("Expected this compressed sparse format {expected}; got {got}.")]
    SparseLayoutMismatch {
        /// The expected [CompressedSparseFormat] enum.
        expected: CompressedSparseFormat,
        /// The provided [CompressedSparseFormat] enum.
        got: CompressedSparseFormat,
    },

    /// Shape mismatch problem for matrices
    #[error("Expected this shape {expected:?}; got {got:?}.")]
    ShapeMismatch {
        /// Expected shape
        expected: (usize, usize),
        /// Provided shape
        got: (usize, usize),
    },

    // -- Binary file I/O --
    /// Wraps any `std::io::Error` encountered while reading or writing the
    /// bixverse binary sparse format.
    ///
    /// Covers file creation, seeks, buffered writes, and raw reads against
    /// the memory-mapped region. Use `#[from]` conversion so `?` works on
    /// any `io::Result`.
    #[cfg(feature = "single-cell")]
    #[error("I/O error on binary file: {0}")]
    BinaryIo(#[from] io::Error),

    /// Error if counts do not seem to be raw counts during i/o
    #[cfg(feature = "single-cell")]
    #[error("The counts you are trying to load in do not seem to be raw counts")]
    NotRawCounts,

    /// Bincode failed to encode the `FileHeader` or `SparseDataHeader`.
    ///
    /// Practically unreachable for the fixed-layout headers used here, but
    /// kept as a non-panicking path so the writer never aborts a long ingest.
    #[cfg(feature = "single-cell")]
    #[error("Failed to encode file header")]
    HeaderEncodeFailed,

    /// Bincode failed to decode the `FileHeader` or `SparseDataHeader`.
    ///
    /// Indicates the file is truncated, corrupt, or was not produced by
    /// bixverse. The 64-byte fixed file header is the first thing read, so
    /// most "wrong file type" errors surface here.
    #[cfg(feature = "single-cell")]
    #[error("Failed to decode file header - file may be corrupt or truncated")]
    HeaderDecodeFailed,

    /// File version does not match the current `SC_FILE_VERSION`.
    ///
    /// Returned on read when an older or newer binary is opened. The fix is
    /// to regenerate the file with the current bixverse version.
    #[cfg(feature = "single-cell")]
    #[error("File version mismatch: expected {expected}, got {found}")]
    FileVersionMismatch {
        /// Version the current build expects.
        expected: u32,
        /// Version actually read from the file header.
        found: u32,
    },

    /// A chunk buffer was shorter than the minimum header length.
    ///
    /// Raised before any field is parsed from a decompressed chunk, so the
    /// reader can fail early instead of indexing out of bounds.
    #[cfg(feature = "single-cell")]
    #[error("Chunk buffer too small: expected at least {expected} bytes, got {found}")]
    ChunkBufferTooSmall {
        /// Minimum bytes required for the chunk header.
        expected: usize,
        /// Actual bytes available in the buffer.
        found: usize,
    },

    /// LZ4 decompression of a chunk failed.
    ///
    /// The offset is reported to make corrupt regions locatable. Usually
    /// indicates file truncation or a mismatched compressed-size prefix.
    #[cfg(feature = "single-cell")]
    #[error("Failed to decompress chunk at offset {0}")]
    ChunkDecompressionFailed(u64),

    /// A caller-supplied original index was not present in the file's index
    /// map.
    ///
    /// Indicates either a bug in the calling code (asking for an index that
    /// was never written) or a file that was generated with a different set
    /// of cells/genes than the caller assumes.
    #[cfg(feature = "single-cell")]
    #[error("Chunk index {0} not found in file index map")]
    ChunkIndexNotFound(usize),

    /// Two stores that must share a gene axis disagree on how many genes
    /// they hold.
    ///
    /// Raised where a method takes a gene-major and a cell-major reader over
    /// what is meant to be the same experiment. Nothing ties the two files
    /// together, so the check has to be explicit: indices derived from one
    /// store are used against buffers sized from the other.
    #[cfg(feature = "single-cell")]
    #[error(
        "The gene store holds {gene_store} genes but the cell store holds {cell_store}; they must describe the same experiment"
    )]
    GeneAxisMismatch {
        /// Total genes reported by the gene-major store
        gene_store: usize,
        /// Total genes reported by the cell-major store
        cell_store: usize,
    },

    /// A cell-based reader method was called on a gene-based file, or vice
    /// versa.
    ///
    /// Replaces the previous runtime asserts. The two string fields describe
    /// the file's actual layout and the layout the caller requested.
    #[cfg(feature = "single-cell")]
    #[error("Reader mode mismatch: file is {actual}, requested {requested}")]
    ReaderModeMismatch {
        /// What the file is: "cell-based" or "gene-based".
        actual: &'static str,
        /// What the caller requested: "cell-based" or "gene-based".
        requested: &'static str,
    },

    /// A chunk's declared payload lengths exceed the bytes actually present.
    ///
    /// The three length fields in a chunk header are untrusted input. This is
    /// raised after they have been parsed but before any payload slice is
    /// taken, so a corrupt length cannot index out of bounds.
    #[cfg(feature = "single-cell")]
    #[error("Chunk payload truncated: header declares {expected} bytes, buffer holds {found}")]
    ChunkPayloadTruncated {
        /// Total bytes the header's length fields imply.
        expected: usize,
        /// Actual bytes available in the decompressed chunk.
        found: usize,
    },

    /// The raw-count element-size discriminant was not recognised.
    ///
    /// Valid values are `RAW_ELEM_U16` (2), `RAW_ELEM_U32` (4), and 0 for
    /// legacy files where the byte was zero padding. Anything else means the
    /// chunk is corrupt; reinterpreting it would misparse the whole payload.
    #[cfg(feature = "single-cell")]
    #[error("Invalid raw count element size discriminant: {0}")]
    RawElemSizeInvalid(u8),

    /// The file is not a bixverse count archive.
    #[cfg(feature = "single-cell")]
    #[error("Not a bixverse count archive (magic string mismatch)")]
    ArchiveMagicMismatch,

    /// The archive was written by an incompatible archive format version.
    #[cfg(feature = "single-cell")]
    #[error("Archive version mismatch: expected {expected}, got {found}")]
    ArchiveVersionMismatch {
        /// Version the current build expects.
        expected: u32,
        /// Version actually read from the archive.
        found: u32,
    },

    /// The archive is truncated or corrupt, or a chunk cannot be archived.
    #[cfg(feature = "single-cell")]
    #[error("Corrupt count archive: {0}")]
    ArchiveCorrupt(String),

    /// A raw count read from disk does not fit the requested numeric type.
    ///
    /// Raised by `from_gene_chunks` / `from_cell_chunks` instead of silently
    /// saturating. Pick a wider `T` (`u32`, `f32`, `f64`) for the affected
    /// dataset.
    #[cfg(feature = "single-cell")]
    #[error("Raw count {value} does not fit target type {target_type}")]
    RawCountOverflow {
        /// The count that could not be represented.
        value: u32,
        /// Name of the type it was being converted into.
        target_type: &'static str,
    },

    /// The `target_size` recorded in a file header disagrees with the one the
    /// caller requested.
    ///
    /// Undoing a file's own library-size normalisation requires the exact
    /// factor the writer used, so a mismatch always yields wrong numbers.
    /// Files written before `target_size` entered the header report `0.0` and
    /// are exempt from the check.
    #[cfg(feature = "single-cell")]
    #[error(
        "Target size mismatch: file was normalised against {header}, but {requested} was requested"
    )]
    TargetSizeMismatch {
        /// Value stored in the file header.
        header: f32,
        /// Value the caller supplied.
        requested: f32,
    },

    /// Error for h5 ingestion if feature type is not found
    #[cfg(feature = "single-cell")]
    #[error(
        "The requested feature type ({requested}) was not found. On file found features are {found}"
    )]
    FeatureTypeNotFound {
        /// Requested feature type
        requested: String,
        /// Found feature types
        found: String,
    },

    /// Error if the h5 string type cannot be read
    #[cfg(feature = "single-cell")]
    #[error("h5: Unexpected str type identified: {0}")]
    H5UnexpectedStringType(String),

    // -- HDF5 / h5ad --
    /// Wraps any error from the `hdf5` crate.
    ///
    /// Covers file open failures, missing datasets (`X/data`, `X/indices`,
    /// `X/indptr`, `obs/*`), dtype mismatches, and slice reads. The wrapped
    /// error carries the original HDF5 message.
    #[cfg(feature = "single-cell")]
    #[error("HDF5 error: {0}")]
    Hdf5(#[from] hdf5::Error),

    /// Provide custom error message for unsupported file formats.
    #[cfg(feature = "single-cell")]
    #[error("Unknown or unsupported h5ad format: {0}")]
    UnsupportH5ADFormat(String),

    /// A dataset is compressed with a filter this build cannot decode.
    ///
    /// Raised before the first read, because HDF5 itself reports the missing
    /// filter as a missing plugin directory: the path baked in at build time
    /// is a cargo `OUT_DIR` that no longer exists. Either re-save the file
    /// with `compression="gzip"`, or build with the `hdf5-filters` feature,
    /// which adds LZF and Blosc.
    #[cfg(feature = "single-cell")]
    #[error(
        "HDF5 dataset '{dataset}' uses filter {filter_id} ({filter_name}), which this build cannot decode"
    )]
    UnsupportedH5Filter {
        /// Path of the dataset inside the file.
        dataset: String,
        /// The HDF5 filter identifier.
        filter_id: i32,
        /// Human readable name for that identifier.
        filter_name: String,
    },

    /// A named `obs` column was requested but does not exist in the h5ad.
    ///
    /// Primarily raised by `write_h5_normalised_counts` when the caller
    /// passes an `obs_lib_size_col` that isn't in the file.
    #[cfg(feature = "single-cell")]
    #[error("obs column '{0}' not found in h5ad file")]
    ObsColumnMissing(String),

    /// The library-size vector read from `obs` does not match the number of
    /// cells in `X`.
    ///
    /// Indicates a malformed h5ad — typical cause is a user-edited obs table
    /// whose row count drifted from the matrix.
    #[cfg(feature = "single-cell")]
    #[error("Library size column length ({found}) does not match cell count ({expected})")]
    LibSizeLengthMismatch {
        /// Expected length (number of cells in `X`).
        expected: usize,
        /// Actual length of the obs column.
        found: usize,
    },

    // -- MTX --
    /// The header of an MTX file is malformed.
    ///
    /// The static string describes which part of the header failed (shape
    /// line missing, wrong field count, unparseable counts). Comment lines
    /// starting with `%` are skipped before this fires.
    #[cfg(feature = "single-cell")]
    #[error("Invalid MTX header: {0}")]
    MtxHeaderInvalid(&'static str),

    /// A field of the MTX shape line could not be parsed as a number.
    ///
    /// `field` names the offending column ("cell count", "gene count",
    /// "entry count") so that malformed inputs can be diagnosed without
    /// quoting user data. Which of the first two fields is which depends on
    /// the `cells_as_rows` orientation.
    #[cfg(feature = "single-cell")]
    #[error("Failed to parse MTX value as {field}")]
    MtxParseError {
        /// Which field failed: "cell count", "gene count", or "entry count".
        field: &'static str,
    },
    // -- Batch --
    /// Need at least two batches for this method
    #[cfg(feature = "single-cell")]
    #[error("You need at least two batches. Provided {n_batches} batches.")]
    NeedAtLeastTwoBatches {
        /// Number of provided batches
        n_batches: usize,
    },

    // -- Integration metrics --
    /// Need at least two distinct labels for this metric
    #[cfg(feature = "single-cell")]
    #[error("You need at least two distinct labels. Provided {n_labels} labels.")]
    NeedAtLeastTwoLabels {
        /// Number of distinct labels found
        n_labels: usize,
    },

    /// The label vector does not line up with the number of cells
    #[cfg(feature = "single-cell")]
    #[error("Expected {n_cells} labels, one per cell; got {n_labels}.")]
    MetricLabelLengthMismatch {
        /// Number of cells in the embedding or kNN graph
        n_cells: usize,
        /// Length of the provided label vector
        n_labels: usize,
    },

    /// A batch holds too few cells for the requested per-batch neighbour count
    #[cfg(feature = "single-cell")]
    #[error("BBKNN: batch {batch} has {n_cells} cells, needs at least {required}.")]
    BbknnBatchTooSmall {
        /// Batch label
        batch: usize,
        /// Cells in the batch
        n_cells: usize,
        /// Minimum required, i.e. `neighbours_within_batch + 1`
        required: usize,
    },

    /// A batch has too few cells for the requested anchor search
    #[cfg(feature = "single-cell")]
    #[error("Batch {batch} has {n_cells} cells, needs at least {required} for anchor finding.")]
    TooFewCellsForAnchor {
        /// Batch index
        batch: usize,
        /// Cells in the batch
        n_cells: usize,
        /// Minimum required
        required: usize,
    },
    // -- Harmony --
    /// Sigma length is not equal to the number of clusters
    #[cfg(feature = "single-cell")]
    #[error("Harmony: sigma length must match number of clusters")]
    HarmonySigmaLengthUnequalCluster,

    // -- Format parsing --
    /// The `cs_type` string did not match a known sparse format.
    ///
    /// Accepted values are "csc" and "csr" (case-insensitive at the parser
    /// level). The unrecognised input is echoed back in the message.
    #[cfg(feature = "single-cell")]
    #[error("Unknown compressed sparse format: '{0}' (expected 'csc' or 'csr')")]
    UnknownSparseFormat(String),

    // -- PCA --
    /// Error if user wants CLR-normalised PCA, but did not provide the offsets
    #[cfg(feature = "single-cell")]
    #[error(
        "You did not provide the offsets needed for using the CLR-normalised PCA in single cell."
    )]
    OffsetsNotProvidedForClrPCA,

    /// Error if user wants CLR-normalised PCA, but did not provide the offsets
    #[cfg(feature = "single-cell")]
    #[error(
        "The provided offsets have length {len_offset}; n_cells is {n_cells}. Length mismatch."
    )]
    OffsetsLengthDoesNotMatchNCells {
        /// Length of the offsets
        len_offset: usize,
        /// Number of cells provided
        n_cells: usize,
    },

    // -- HVG --
    /// A requested cell index does not exist in the store.
    #[cfg(feature = "single-cell")]
    #[error("HVG: cell index {index} is outside the store, which holds {total_cells} cells.")]
    HvgCellIndexOutOfRange {
        /// The offending cell index
        index: usize,
        /// Number of cells the store holds
        total_cells: usize,
    },

    /// The same cell was selected twice, which would inflate the denominator.
    #[cfg(feature = "single-cell")]
    #[error("HVG: cell index {index} appears more than once in the selection.")]
    HvgDuplicateCellIndex {
        /// The duplicated cell index
        index: usize,
    },

    /// Batch labels and cell indices disagree in length.
    #[cfg(feature = "single-cell")]
    #[error("HVG: {n_labels} batch labels were given for {n_cells} selected cells.")]
    HvgBatchLabelLengthMismatch {
        /// Number of batch labels provided
        n_labels: usize,
        /// Number of selected cells
        n_cells: usize,
    },

    /// A batch in `0..n_batches` ended up with no cells.
    #[cfg(feature = "single-cell")]
    #[error("HVG: batch {batch} has no cells; labels must densely cover 0..{n_batches}.")]
    HvgEmptyBatch {
        /// The empty batch
        batch: usize,
        /// Number of batches implied by the labels
        n_batches: usize,
    },

    /// No cells were selected at all.
    #[cfg(feature = "single-cell")]
    #[error("HVG: the cell selection is empty.")]
    HvgEmptySelection,

    /// Loess span outside the range [`crate::core::base::loess::LoessRegression`]
    /// accepts.
    #[cfg(feature = "single-cell")]
    #[error("HVG: the loess span is {span}, but it must be within (0, 1].")]
    HvgInvalidLoessSpan {
        /// The provided span
        span: f32,
    },

    /// Bin count of zero, which would divide by zero during binning.
    #[cfg(feature = "single-cell")]
    #[error("HVG: n_bins must be at least 1.")]
    HvgInvalidBinCount,

    /// Too few genes pass the mean filter to fit the scran variance trend.
    #[cfg(feature = "single-cell")]
    #[error(
        "HVG: only {n_kept} genes have a mean log-expression of at least {minimum_mean}; the variance trend needs two."
    )]
    HvgTooFewTrendPoints {
        /// Genes left after the mean filter
        n_kept: usize,
        /// The mean filter threshold
        minimum_mean: f64,
    },

    // -- NMF --
    /// NMF Rank is too large for the NNDSVD initialisation
    #[error(
        "The requested NMF ({requested}) rank is too large for NNDSVD initialisation (available: {available})."
    )]
    NmfRankTooLarge {
        /// Requested rank
        requested: usize,
        /// Maximum available rank
        available: usize,
    },

    /// Error if NMF values are not finite
    #[error("The NMF values are not finite")]
    NmfNonFinite,

    /// Error if NMF values are negative
    #[error("Negative values were discovered for NMF. Please check the inputs.")]
    NmfNonNegativeViolated,

    /// Consensus NMF received a rank that cannot support a silhouette.
    #[error("Consensus NMF needs k >= 2, but k = {k} was requested.")]
    NmfConsensusInvalidK {
        /// Requested rank
        k: usize,
    },

    /// Consensus NMF received too few restarts to pool over.
    #[error("Consensus NMF needs at least 2 restarts, but n_runs = {n_runs} was requested.")]
    NmfConsensusTooFewRuns {
        /// Requested number of restarts
        n_runs: usize,
    },

    /// The outlier filter removed so many components that k-means cannot run.
    #[error(
        "Consensus NMF: only {n_surviving} components survived the density filter, which is fewer than k = {k}. Raise the density threshold or add restarts."
    )]
    NmfConsensusTooFewComponents {
        /// Requested rank
        k: usize,
        /// Components left after filtering
        n_surviving: usize,
    },

    /// k-means returned an empty cluster, so the consensus factor would carry a
    /// zero row and the refit would be degenerate.
    #[error(
        "Consensus NMF: cluster {cluster} of {k} is empty, so the consensus factor is degenerate."
    )]
    NmfConsensusEmptyCluster {
        /// Index of the empty cluster
        cluster: usize,
        /// Requested rank
        k: usize,
    },

    /// The k sweep was handed nothing to sweep over.
    #[error("The NMF k sweep was given an empty k range.")]
    NmfKSweepEmptyRange,

    /// A consensus step was applied to restarts run at a different rank.
    #[error(
        "Consensus NMF was asked for k = {requested}, but the restarts were run at k = {restarts}."
    )]
    NmfConsensusKMismatch {
        /// Rank requested for the consensus
        requested: usize,
        /// Rank the restarts actually hold
        restarts: usize,
    },

    /// A frozen factor handed to a refit does not line up with V.
    #[error(
        "NMF refit: the frozen factor has a shared dimension of {found}, but V expects {expected}."
    )]
    NmfDimensionMismatch {
        /// Dimension V expects
        expected: usize,
        /// Dimension the frozen factor provides
        found: usize,
    },

    // -- LDA --
    /// The requested topic count cannot be supported by the corpus.
    ///
    /// A topic needs somewhere to put its mass, so `k` above the vocabulary
    /// size leaves topics that no document can ever distinguish.
    #[error(
        "LDA: the requested topic count ({requested}) exceeds what the corpus supports (max: {max_available})."
    )]
    LdaInvalidTopicCount {
        /// Requested number of topics
        requested: usize,
        /// Largest topic count the corpus supports
        max_available: usize,
    },

    /// A Dirichlet hyperparameter is not strictly positive.
    #[error("LDA: hyperparameter '{name}' must be strictly positive, got {value}.")]
    LdaInvalidHyperparameter {
        /// Name of the offending hyperparameter
        name: String,
        /// Value that was supplied
        value: f64,
    },

    /// The document-term matrix carries no counts at all.
    #[error("LDA: the document-term matrix holds no non-zero entries.")]
    LdaEmptyMatrix,

    /// A non-finite value reached the variational parameters.
    #[error("LDA: the variational parameters became non-finite. Please check the inputs.")]
    LdaNonFinite,

    /// Coherence was asked for more top terms than the vocabulary holds.
    #[error(
        "LDA: coherence requested the top {requested} terms, but the vocabulary holds {vocab_size}."
    )]
    LdaTopNTooLarge {
        /// Requested number of top terms per topic
        requested: usize,
        /// Vocabulary size available
        vocab_size: usize,
    },

    /// Metric inputs disagree on the number of documents or topics.
    #[error("LDA metrics: expected a dimension of {expected}, but received {got}.")]
    LdaDimensionMismatch {
        /// Dimension the model implies
        expected: usize,
        /// Dimension that was supplied
        got: usize,
    },

    // -- Hotspot --
    /// Invalid model chosen for Hotspot
    #[cfg(feature = "single-cell")]
    #[error("Invalid model type: {0}")]
    HotSpotWrongModel(String),

    // -- Metacells2 --
    /// User needs to have generated select_features prior to the
    /// compute_similarity part.
    #[cfg(feature = "single-cell")]
    #[error("select_features must be called before compute_similarity!")]
    SelectFeaturesBeforeSimilariy,

    // -- Seacells --
    /// The SEACells kernel matrix has not been yet constructed.
    #[cfg(feature = "single-cell")]
    #[error("SEACells: You must construct the kernel matrix first.")]
    SEACellsKernelMatrixMissing,

    /// The SEACells archetypes have not yet been identified.
    #[cfg(feature = "single-cell")]
    #[error("SEACells: You must first initialise the Archetypes.")]
    SEACellsArchetypesMissing,

    /// The SEACells archetypes have not yet been identified.
    #[cfg(feature = "single-cell")]
    #[error("SEACells: The model has not been fitted yet. Please run .fit()")]
    SEACellsModelNotFitted,

    /// The adaptive diffusion kernel came out with non-finite weights.
    ///
    /// Raised by
    /// [crate::single_cell::mc_generation::seacells::compute_diffusion_kernel].
    /// The usual cause is a caller-supplied kNN graph carrying `NaN` distances,
    /// since zero bandwidths are handled by the smallest-positive fallback.
    #[cfg(feature = "single-cell")]
    #[error(
        "The adaptive diffusion kernel over {n_cells} cells holds non-finite weights; check the supplied kNN distances"
    )]
    DiffusionKernelNotFinite {
        /// Cells in the kNN graph
        n_cells: usize,
    },

    // -- FastCluster --
    /// The Fast cluster results do not contain k-means cluster assignments
    #[cfg(feature = "single-cell")]
    #[error("The fast cluster results were generated without any k means cluster assignments")]
    FastClusterNoKmeansAssignments,

    /// The Fast cluster results do not contain k-means centroids
    #[cfg(feature = "single-cell")]
    #[error("The fast cluster results were generated without any centroids")]
    FastClusterNoCentroids,

    // -- MELD --
    /// If the user provides a label which is out-of-range of the expected
    /// number of groups
    #[cfg(feature = "single-cell")]
    #[error("MELD: label {label} out of range for n_groups={n_groups}.")]
    MELDLabelOutOfRange {
        /// The label which is out of range
        label: usize,
        /// The number of expected groups
        n_groups: usize,
    },

    /// If the user asks for less than two Chebyshev coefficients
    #[cfg(feature = "single-cell")]
    #[error("MELD: Need at least 2 Chebyshev coefficients")]
    MELDChebyshevCoefTooLow,

    /// Error if labels and number of cells are not equal
    #[cfg(feature = "single-cell")]
    #[error("MELD: The labels are not equal to the number of cells")]
    MELDLabelUnequalsSamples,

    /// Error of n_groups < 2
    #[cfg(feature = "single-cell")]
    #[error("MELD: labels needs two groups minimum")]
    MELDOnlyOneGroup,

    // -- DGE --
    /// Fewer than two cell groups, so there is nothing to compare.
    #[cfg(feature = "single-cell")]
    #[error("DGE: at least two cell groups are required, got {n_groups}")]
    DgeTooFewGroups {
        /// Number of groups supplied
        n_groups: usize,
    },

    /// More cell groups than the `u16` group labels can hold.
    #[cfg(feature = "single-cell")]
    #[error("DGE: {n_groups} cell groups exceed the maximum of {max}")]
    DgeTooManyGroups {
        /// Number of groups supplied
        n_groups: usize,
        /// Largest supported number of groups
        max: usize,
    },

    /// One of the cell groups holds no cells.
    #[cfg(feature = "single-cell")]
    #[error("DGE: cell group {group} is empty")]
    DgeEmptyGroup {
        /// Index of the offending group
        group: usize,
    },

    /// A cell index beyond the end of the store.
    #[cfg(feature = "single-cell")]
    #[error("DGE: cell {cell} is out of range for a store of {n_cells} cells")]
    DgeCellOutOfRange {
        /// The offending cell index
        cell: usize,
        /// Total cells in the store
        n_cells: usize,
    },

    /// A cell appears in two groups, or twice in one.
    ///
    /// Silently biases every AUROC toward 0.5 rather than failing, so it is
    /// worth rejecting outright.
    #[cfg(feature = "single-cell")]
    #[error("DGE: cell {cell} appears in cell group {first} and again in group {second}")]
    DgeOverlappingGroups {
        /// The cell listed more than once
        cell: usize,
        /// Group the cell was first seen in
        first: usize,
        /// Group the cell was seen in again
        second: usize,
    },

    /// A reference arm index beyond the number of groups.
    #[cfg(feature = "single-cell")]
    #[error("DGE: reference {reference} is out of range for {n_groups} cell groups")]
    DgeReferenceOutOfRange {
        /// The offending reference index
        reference: usize,
        /// Number of groups supplied
        n_groups: usize,
    },

    // -- NEBULA --
    /// Every gene failed NEBULA's own expression filter.
    ///
    /// Raised only once the whole sweep is done. A single batch losing all of
    /// its genes is normal and is skipped silently.
    #[cfg(feature = "single-cell")]
    #[error(
        "NEBULA: none of the {n_genes} genes passed the expression filter; lower `cpc` or `mincp`"
    )]
    NebulaNoGenesKept {
        /// How many genes were offered
        n_genes: usize,
    },

    // -- Palantir --
    /// The user-supplied early cell index sits outside the cell range.
    #[cfg(feature = "single-cell")]
    #[error("Palantir: early cell index {early_cell} is out of range for {n_cells} cells")]
    PalantirEarlyCellOutOfRange {
        /// The offending early cell index
        early_cell: usize,
        /// Number of cells in the input
        n_cells: usize,
    },

    /// A user-supplied terminal state index sits outside the cell range.
    #[cfg(feature = "single-cell")]
    #[error("Palantir: terminal state index {state} is out of range for {n_cells} cells")]
    PalantirTerminalStateOutOfRange {
        /// The offending terminal state index
        state: usize,
        /// Number of cells in the input
        n_cells: usize,
    },

    /// A terminal state cell was never sampled as a waypoint, so it has no
    /// position in the waypoint Markov chain.
    #[cfg(feature = "single-cell")]
    #[error(
        "Palantir: terminal state cell {state} is not among the {n_waypoints} sampled waypoints"
    )]
    PalantirTerminalStateNotAWaypoint {
        /// The offending terminal state cell index
        state: usize,
        /// Number of sampled waypoints
        n_waypoints: usize,
    },

    /// The first waypoint is not the start cell, which every consumer of the
    /// geodesic matrix assumes.
    #[cfg(feature = "single-cell")]
    #[error(
        "Palantir: waypoint 0 is cell {first_waypoint}, but the start cell is {start_cell}; the start cell must be the first waypoint"
    )]
    PalantirStartCellNotFirstWaypoint {
        /// The start cell the caller asked for
        start_cell: usize,
        /// The cell actually sitting in waypoint position zero
        first_waypoint: usize,
    },

    /// The pseudotime refinement cap leaves no room for a single pass.
    #[cfg(feature = "single-cell")]
    #[error(
        "Palantir: max_iterations is {max_iterations}, but at least {minimum} is needed to run a refinement pass"
    )]
    PalantirMaxIterationsTooSmall {
        /// The requested cap
        max_iterations: usize,
        /// Smallest cap that runs at least one pass
        minimum: usize,
    },

    /// The neighbour count is too small to derive the back-edge bandwidth of
    /// the waypoint Markov chain, which indexes neighbour rank
    /// `min(knn / 3 - 1, 30)`.
    ///
    /// Carries the neighbour count the caller asked for, not the one clamped to
    /// the waypoint set. Too few waypoints raises
    /// [BixverseErrors::PalantirTooFewWaypoints] instead, so the message always
    /// names the parameter that can actually be changed.
    #[cfg(feature = "single-cell")]
    #[error("Palantir: knn is {knn}, but at least {minimum} is needed for the adaptive bandwidth")]
    PalantirKnnTooSmall {
        /// The requested neighbour count
        knn: usize,
        /// Smallest neighbour count yielding a valid bandwidth rank
        minimum: usize,
    },

    /// Too few waypoints were sampled to support the back-edge bandwidth rank,
    /// however large the requested `knn` is.
    #[cfg(feature = "single-cell")]
    #[error(
        "Palantir: only {n_waypoints} waypoints were sampled, but at least {minimum} are needed for the adaptive bandwidth; raise num_waypoints or supply more cells"
    )]
    PalantirTooFewWaypoints {
        /// Waypoints actually sampled
        n_waypoints: usize,
        /// Smallest waypoint count yielding a valid bandwidth rank
        minimum: usize,
    },

    /// The waypoint data and the waypoint pseudotime disagree on the waypoint
    /// count.
    #[cfg(feature = "single-cell")]
    #[error(
        "Palantir: {n_waypoints} waypoint rows were supplied against {n_pseudotime} pseudotime values"
    )]
    PalantirWaypointDataMismatch {
        /// Rows of multiscale data restricted to the waypoints
        n_waypoints: usize,
        /// Pseudotime values supplied alongside
        n_pseudotime: usize,
    },

    /// The stationary ranks have no spread, so the outlier cutoff separates
    /// nothing.
    ///
    /// More than half the waypoints share a stationary mass, which puts the
    /// median absolute deviation at exactly zero and the cutoff exactly on the
    /// median. Roughly half of everything then clears it, the components merge,
    /// and the branching structure collapses to a single terminal state.
    #[cfg(feature = "single-cell")]
    #[error(
        "Palantir: the stationary ranks over {n_waypoints} waypoints have zero spread, so terminal states cannot be separated; supply them explicitly or raise knn"
    )]
    PalantirStationaryRanksDegenerate {
        /// Waypoints in the chain
        n_waypoints: usize,
    },

    /// Cells remained unreachable from the start cell after graph repair.
    #[cfg(feature = "single-cell")]
    #[error(
        "Palantir: {n_unreachable} cells remain unreachable from the start cell after {repairs} repair edges; increase knn"
    )]
    PalantirDisconnectedGraph {
        /// Cells still at infinite geodesic distance
        n_unreachable: usize,
        /// Repair edges added before giving up
        repairs: usize,
    },

    /// The pseudotime collapsed to a constant, or the Silverman bandwidth was
    /// zero or non-finite, so the refinement cannot be normalised.
    #[cfg(feature = "single-cell")]
    #[error("Palantir: degenerate pseudotime ({reason}); the manifold is probably collapsed")]
    PalantirDegeneratePseudotime {
        /// Which quantity degenerated
        reason: &'static str,
    },

    /// The stationary power iteration lost all of its mass, which can only
    /// happen when the supplied chain is not row-stochastic or carries
    /// non-finite entries.
    #[cfg(feature = "single-cell")]
    #[error(
        "Palantir: the stationary power iteration lost its mass at iteration {iteration} (total mass {total:e}); the transition matrix is not row-stochastic"
    )]
    PalantirStationaryMassLost {
        /// The pass on which the mass went non-positive or non-finite
        iteration: usize,
        /// The offending total mass
        total: f64,
    },

    /// An eigenvalue of the diffusion operator came back non-finite.
    ///
    /// Clamping it would fabricate a plausible-looking scale factor out of a
    /// `NaN`, since `f64::min` returns the other operand for a `NaN` input.
    #[cfg(feature = "single-cell")]
    #[error(
        "Palantir: the diffusion eigensolve returned a non-finite eigenvalue ({eigenvalue}); the kernel is degenerate"
    )]
    PalantirNonFiniteEigenvalue {
        /// The offending eigenvalue
        eigenvalue: f64,
    },

    /// An explicit `n_eigs` below the smallest usable count was requested.
    ///
    /// The trivial leading eigenvector is always dropped, so fewer than two
    /// eigenvectors leaves no multiscale coordinate at all. The request is
    /// refused rather than raised to the heuristic's floor: silently changing a
    /// pinned count changes every distance downstream.
    #[cfg(feature = "single-cell")]
    #[error(
        "Palantir: n_eigs is {n_eigs}, but at least {minimum} eigenvectors are needed for a multiscale space"
    )]
    PalantirNEigsTooSmall {
        /// The requested eigenvector count
        n_eigs: usize,
        /// Smallest count leaving a usable coordinate
        minimum: usize,
    },

    /// No terminal states survived the rank cutoff and the caller supplied none.
    #[cfg(feature = "single-cell")]
    #[error(
        "Palantir: no terminal states detected; supply them explicitly or increase num_waypoints"
    )]
    PalantirNoTerminalStates,

    /// The multiscale space has no boundary cells, so there is nothing to snap
    /// the start cell or a terminal-state candidate onto.
    ///
    /// Boundaries are the per-component argmin and argmax, so an empty set means
    /// the multiscale space has no columns at all.
    #[cfg(feature = "single-cell")]
    #[error("Palantir: the multiscale space has no boundary cells to snap onto")]
    PalantirNoBoundaryCells,

    /// Cells remained unreachable from the waypoint set when the geodesic
    /// matrix was built.
    ///
    /// Distinct from [BixverseErrors::PalantirDisconnectedGraph], which reports
    /// the repair attempts. This fires after repair, inside the pseudotime
    /// solve, where the repair count is not in scope and claiming zero of them
    /// would be a lie.
    #[cfg(feature = "single-cell")]
    #[error("Palantir: {n_unreachable} cells have no geodesic path to some waypoint; increase knn")]
    PalantirUnreachableFromWaypoints {
        /// Cells at infinite geodesic distance from at least one waypoint
        n_unreachable: usize,
    },

    /// The densified `(I - Q) B = R` solve did not converge.
    ///
    /// Carries the achieved `‖(I - Q) B - R‖_inf`, which is what actually
    /// distinguishes a near-singular system from ordinary rounding.
    #[cfg(feature = "single-cell")]
    #[error(
        "Palantir: the (I - Q) B = R solve over {n_transient} transient waypoints left a residual of {residual:e}, above tolerance"
    )]
    PalantirAbsorbingSolveFailed {
        /// Achieved `‖(I - Q) B - R‖_inf`
        residual: f64,
        /// Total transient waypoints
        n_transient: usize,
    },

    /// The absorption probabilities of one waypoint sum to more than one.
    ///
    /// Split out from [BixverseErrors::PalantirAbsorbingSolveFailed] because the
    /// solve residual says nothing here: the factorisation can be exact to
    /// machine precision and still land on this, which is what makes the
    /// offending sum the only number worth reporting.
    #[cfg(feature = "single-cell")]
    #[error(
        "Palantir: waypoint {waypoint} absorbs with probability {row_sum} across {n_transient} transient waypoints, which is above one"
    )]
    PalantirAbsorbingRowSum {
        /// The offending row sum
        row_sum: f64,
        /// Waypoint index the row belongs to
        waypoint: usize,
        /// Total transient waypoints
        n_transient: usize,
    },

    /// The absorption probabilities of one waypoint are not finite.
    ///
    /// Split from [BixverseErrors::PalantirAbsorbingRowSum] because the two
    /// point at different bugs: a non-finite sum means a `NaN` reached the
    /// solve, while a sum above one means the chain was not sub-stochastic.
    #[cfg(feature = "single-cell")]
    #[error(
        "Palantir: waypoint {waypoint} has a non-finite absorption row sum ({row_sum}) across {n_transient} transient waypoints; the solve produced non-finite entries"
    )]
    PalantirAbsorbingRowNotFinite {
        /// The offending row sum
        row_sum: f64,
        /// Waypoint index the row belongs to
        waypoint: usize,
        /// Total transient waypoints
        n_transient: usize,
    },

    /// The densified `(I - Q)` would exceed the supported size.
    #[cfg(feature = "single-cell")]
    #[error(
        "Palantir: {n_transient} transient waypoints exceed the dense solve limit of {limit}; lower num_waypoints"
    )]
    PalantirTooManyWaypointsForDenseSolve {
        /// Transient waypoint count requested
        n_transient: usize,
        /// The supported ceiling
        limit: usize,
    },

    // -- MAGIC --
    /// The requested imputation would not fit in a sane amount of memory.
    ///
    /// MAGIC output is unavoidably dense, so the only bound available is the
    /// caller's gene selection. This is an error rather than a warning because
    /// the crate is driven from R, where a printed warning is easily missed and
    /// the alternative failure mode is the session being OOM-killed.
    #[cfg(feature = "single-cell")]
    #[error(
        "MAGIC: imputing {n_genes} genes across {n_cells} cells needs {} MB of dense f32, above the {} MB budget; subset the genes or set allow_large",
        (*n_cells as f64 * *n_genes as f64 * 4.0 / 1.048576e6).round(),
        (*max_elements as f64 * 4.0 / 1.048576e6).round()
    )]
    MagicOutputTooLarge {
        /// Cells in the selection
        n_cells: usize,
        /// Genes in the selection
        n_genes: usize,
        /// The element ceiling that was exceeded
        max_elements: usize,
    },

    /// The kNN inputs disagree with the requested cell selection.
    #[cfg(feature = "single-cell")]
    #[error(
        "MAGIC: the kNN graph has {n_knn} rows but {n_selected} cells were selected; the two must be aligned"
    )]
    MagicKnnSelectionMismatch {
        /// Rows in the supplied kNN graph
        n_knn: usize,
        /// Cells in the selection
        n_selected: usize,
    },

    /// A cell index in the selection sits outside the store.
    #[cfg(feature = "single-cell")]
    #[error("MAGIC: cell index {cell} is out of range for a store of {total_cells} cells")]
    MagicCellOutOfRange {
        /// The offending cell index
        cell: usize,
        /// Cells in the store
        total_cells: usize,
    },

    /// A cell index appears twice in the selection.
    #[cfg(feature = "single-cell")]
    #[error("MAGIC: cell index {cell} appears more than once in the selection")]
    MagicDuplicateCell {
        /// The duplicated cell index
        cell: usize,
    },

    /// The operator was built against a different store than the one being read.
    ///
    /// The operator's cell lookup is indexed by global cell id, so reading a
    /// store with more cells than it was sized for would index past its end.
    #[cfg(feature = "single-cell")]
    #[error(
        "MAGIC: the operator was built for a store of {operator_cells} cells but the reader holds {store_cells}; the `total_cells` passed to MagicOperator::from_knn must match the store"
    )]
    MagicStoreSizeMismatch {
        /// Cells the operator was built for
        operator_cells: usize,
        /// Cells the reader actually holds
        store_cells: usize,
    },

    // -- Gene trends --
    /// The expression matrix does not have one row per cell.
    #[cfg(feature = "single-cell")]
    #[error(
        "Gene trends: the expression matrix has {n_cells} rows but {n_pseudotime} pseudotime values were supplied"
    )]
    GeneTrendsShapeMismatch {
        /// Rows in the expression matrix
        n_cells: usize,
        /// Length of the pseudotime vector
        n_pseudotime: usize,
    },

    /// A branch selected no cells at all.
    #[cfg(feature = "single-cell")]
    #[error("Gene trends: branch {branch} selected no cells")]
    GeneTrendsBranchEmpty {
        /// Index of the offending branch
        branch: usize,
    },

    /// A branch has too few cells to fit anything meaningful.
    ///
    /// The reference has no such guard and will happily fit a 500-point grid to
    /// a single cell. The resulting curve is the prior, not the data.
    #[cfg(feature = "single-cell")]
    #[error(
        "Gene trends: branch {branch} has only {n_cells} cells, which is below the minimum of {minimum}"
    )]
    GeneTrendsBranchTooFewCells {
        /// Index of the offending branch
        branch: usize,
        /// Cells the branch selected
        n_cells: usize,
        /// Minimum the fit requires
        minimum: usize,
    },

    /// Every cell in a branch shares the same pseudotime, so there is no grid
    /// to fit over.
    #[cfg(feature = "single-cell")]
    #[error("Gene trends: branch {branch} spans zero pseudotime, so its grid would be degenerate")]
    GeneTrendsDegeneratePseudotime {
        /// Index of the offending branch
        branch: usize,
    },

    /// Fate-probability weighting was asked for without the probabilities.
    #[cfg(feature = "single-cell")]
    #[error(
        "Gene trends: BranchWeighting::FateProbability needs the fate probabilities, but none were supplied"
    )]
    GeneTrendsMissingFateProbabilities,

    /// The fate-probability matrix does not have one column per branch.
    ///
    /// Branch `i` takes column `i` positionally, so a mismatch would silently
    /// weight every cell by the wrong fate rather than failing.
    #[cfg(feature = "single-cell")]
    #[error(
        "Gene trends: the fate probabilities have {n_columns} columns but {n_branches} branches were supplied; branches map to columns positionally, so the two must agree"
    )]
    GeneTrendsFateColumnMismatch {
        /// Columns in the fate probability matrix
        n_columns: usize,
        /// Branches supplied
        n_branches: usize,
    },

    /// The fate-probability matrix does not have one row per cell.
    #[cfg(feature = "single-cell")]
    #[error(
        "Gene trends: the fate probabilities have {n_rows} rows but {n_cells} cells were supplied"
    )]
    GeneTrendsFateShapeMismatch {
        /// Rows in the fate probability matrix
        n_rows: usize,
        /// Cells supplied
        n_cells: usize,
    },

    /// A branch cell index sits outside the cell range.
    #[cfg(feature = "single-cell")]
    #[error(
        "Gene trends: branch {branch} references cell {cell}, which is out of range for {n_cells} cells"
    )]
    GeneTrendsCellOutOfRange {
        /// Index of the offending branch
        branch: usize,
        /// The offending cell index
        cell: usize,
        /// Cells in the input
        n_cells: usize,
    },

    /// Every cell in a branch carries zero fate-probability weight.
    ///
    /// The Gaussian process treats a zero-weight observation as absent, so this
    /// would otherwise return the zero-mean prior and be indistinguishable from
    /// a genuinely flat gene.
    #[cfg(feature = "single-cell")]
    #[error(
        "Gene trends: every cell selected for branch {branch} has zero fate probability, so the fit would carry no data"
    )]
    GeneTrendsZeroWeightBranch {
        /// Index of the offending branch
        branch: usize,
    },

    /// The requested grid is too coarse to fit anything over.
    #[cfg(feature = "single-cell")]
    #[error("Gene trends: a resolution of {resolution} is below the minimum of {minimum}")]
    GeneTrendsResolutionTooLow {
        /// Requested grid points
        resolution: usize,
        /// Minimum the fit requires
        minimum: usize,
    },

    // -- DIALOGUE --
    /// Fewer than two cell types were supplied.
    ///
    /// There is nothing to correlate across with one.
    #[cfg(feature = "single-cell")]
    #[error("DIALOGUE: needs at least two cell types; got {n_cell_types}.")]
    DialogueTooFewCellTypes {
        /// Cell types supplied
        n_cell_types: usize,
    },

    /// Too few samples are represented in every cell type.
    ///
    /// The decomposition runs on the samples the cell types share, and that
    /// intersection is what has come up short.
    #[cfg(feature = "single-cell")]
    #[error(
        "DIALOGUE: only {found} samples are present in every cell type; at least {minimum} are needed."
    )]
    DialogueTooFewSharedSamples {
        /// Samples in the intersection
        found: usize,
        /// Minimum the decomposition requires
        minimum: usize,
    },

    /// Too few features in one cell type survived the ANOVA filter.
    ///
    /// A feature is kept when it varies across samples. If almost none do, the
    /// cell type carries no sample-level signal to decompose, and the usual
    /// fixes are to drop it from the run or to set the spatial flag.
    #[cfg(feature = "single-cell")]
    #[error(
        "DIALOGUE: only {found} of {total} features in cell type {cell_type} passed the ANOVA filter; at least {minimum} are needed."
    )]
    DialogueTooFewFeatures {
        /// Index of the offending cell type
        cell_type: usize,
        /// Features that passed
        found: usize,
        /// Features tested
        total: usize,
        /// Minimum the decomposition requires
        minimum: usize,
    },

    /// Too few samples carry enough cells for the ANOVA filter to run.
    #[cfg(feature = "single-cell")]
    #[error(
        "DIALOGUE: cell type {cell_type} has {found} samples with at least {min_cells} cells; the ANOVA filter needs two. Lower abn_c or set the spatial flag."
    )]
    DialogueTooFewAbundantSamples {
        /// Index of the offending cell type
        cell_type: usize,
        /// Samples meeting the threshold
        found: usize,
        /// Cells a sample must contribute
        min_cells: usize,
    },

    // -- CellPhoneDB --
    /// A partner subunit points outside the genes in the store.
    #[cfg(feature = "single-cell")]
    #[error("CellPhoneDB: gene index {index} is outside the {n_genes} genes in the store.")]
    CpdbGeneIndexOutOfRange {
        /// The offending index
        index: usize,
        /// Number of genes in the store
        n_genes: usize,
    },

    /// A cluster lists a cell outside the store.
    #[cfg(feature = "single-cell")]
    #[error("CellPhoneDB: cell index {index} is outside the {n_cells} cells in the store.")]
    CpdbCellIndexOutOfRange {
        /// The offending index
        index: usize,
        /// Number of cells in the store
        n_cells: usize,
    },

    /// An interaction has a partner without any subunit genes.
    #[cfg(feature = "single-cell")]
    #[error("CellPhoneDB: interaction {interaction} has a partner without genes.")]
    CpdbEmptyPartner {
        /// Index of the offending interaction
        interaction: usize,
    },

    /// A cell was assigned to more than one cluster.
    ///
    /// The permutation shuffles one label per cell, so clusters must be
    /// disjoint.
    #[cfg(feature = "single-cell")]
    #[error("CellPhoneDB: cell {cell} appears in more than one cluster.")]
    CpdbClusterOverlap {
        /// The offending cell index
        cell: usize,
    },

    // -- sctype --
    /// Error when number of cluster assignment != the number of cells
    #[cfg(feature = "single-cell")]
    #[error(
        "SCType: The number of cells ({n_cells}) and cluster assignments length ({n_cluster_assignment}) is not the same."
    )]
    ScTypeClusterAssignmentNotEqualNCells {
        /// Number of cells
        n_cells: usize,
        /// Number of cluster assignments
        n_cluster_assignment: usize,
    },

    /// Error when the smoothing graph node count != the number of cells
    #[cfg(feature = "single-cell")]
    #[error("SCType: The graph has {n_nodes} nodes, but there are {n_cells} cells.")]
    ScTypeGraphNodesNotEqualNCells {
        /// Number of cells
        n_cells: usize,
        /// Number of nodes in the smoothing graph
        n_nodes: usize,
    },

    // -- scTransform --
    /// A step-1 gene index pointed outside the modelled gene set.
    #[cfg(feature = "single-cell")]
    #[error("scTransform: step-1 gene index {index} is outside the {n_genes} modelled genes.")]
    SctGeneIndexOutOfRange {
        /// The offending index
        index: usize,
        /// Number of modelled genes
        n_genes: usize,
    },

    /// Too few step-1 genes survived outlier and Poisson exclusion to smooth.
    ///
    /// Two is the bare minimum a bandwidth can be estimated from. In practice
    /// this means the subsample was dominated by Poisson genes, which is what a
    /// very shallow dataset looks like.
    #[cfg(feature = "single-cell")]
    #[error(
        "scTransform: only {kept} of {total} step-1 genes survived exclusion, too few to regularise."
    )]
    SctTooFewGenesToRegularise {
        /// Genes left after exclusion
        kept: usize,
        /// Genes the step-1 fit started from
        total: usize,
    },

    /// The kernel smoothing produced no value for a gene.
    ///
    /// Means no retained step-1 gene fell inside the kernel support at that
    /// gene's abundance, which a bandwidth this wide should make impossible.
    #[cfg(feature = "single-cell")]
    #[error("scTransform: regularisation left gene {gene} without a fitted parameter.")]
    SctSmoothingLeftAGeneUnfitted {
        /// Position of the gene in the modelled set
        gene: usize,
    },

    /// No gene passed the `min_cells` detection filter.
    #[cfg(feature = "single-cell")]
    #[error("scTransform: no gene of {n_genes} is detected in at least {min_cells} cells.")]
    SctNoGenesPassFilter {
        /// The filter that rejected everything
        min_cells: usize,
        /// Genes the filter was applied to
        n_genes: usize,
    },

    /// A gene handed to the residual PCA is not covered by the model.
    #[cfg(feature = "single-cell")]
    #[error("scTransform: gene {gene} is not covered by the fitted model.")]
    SctGeneNotModelled {
        /// Store index of the gene
        gene: usize,
    },

    /// Pearson residuals and the shifted CLR transformation were both asked
    /// for.
    ///
    /// CLR rewrites the stored log-normalised layer, which the residual path
    /// never reads, so combining them would quietly do nothing.
    #[cfg(feature = "single-cell")]
    #[error("PCA on Pearson residuals cannot be combined with the CLR transformation.")]
    PcaResidualsWithClr,

    /// Covariate columns handed in disagree in length.
    #[cfg(feature = "single-cell")]
    #[error("scTransform covariate '{name}': expected {expected} values, got {found}.")]
    SctCovariateLengthMismatch {
        /// Name of the offending covariate
        name: String,
        /// Length the other columns had
        expected: usize,
        /// Length this one had
        found: usize,
    },

    /// The covariates supplied do not match what the model was fitted with.
    #[cfg(feature = "single-cell")]
    #[error("scTransform model was fitted with {model} covariate(s) but {supplied} were supplied.")]
    SctCovariateCountMismatch {
        /// Covariates the model carries
        model: usize,
        /// Covariates the caller supplied
        supplied: usize,
    },

    // -- residual normalisation --
    /// Group labels disagree in length with the selected cells.
    #[cfg(feature = "single-cell")]
    #[error("Residual grouping: {n_labels} group label(s) for {n_cells} cell(s).")]
    ResidualGroupLabelLengthMismatch {
        /// Labels supplied
        n_labels: usize,
        /// Cells selected
        n_cells: usize,
    },

    /// A group id has no cells in it.
    ///
    /// Labels must densely cover `0..n_groups`. A gap means the caller coded a
    /// factor without dropping its unused levels, and a model would be fitted
    /// against nothing.
    #[cfg(feature = "single-cell")]
    #[error("Residual grouping: group {group} of {n_groups} has no cells.")]
    ResidualEmptyGroup {
        /// The empty group id
        group: usize,
        /// Groups the labels imply
        n_groups: usize,
    },

    /// No gene is modelled in every group.
    ///
    /// The shared feature axis is the intersection of the per-group modelled
    /// sets, so an empty intersection means the groups have no gene in common
    /// that passes `min_cells` everywhere. Usually one very shallow sample.
    #[cfg(feature = "single-cell")]
    #[error("Residual grouping: no gene is modelled in all {n_groups} groups.")]
    ResidualEmptyGeneIntersection {
        /// Groups that were intersected
        n_groups: usize,
    },

    /// The count block handed to the analytic Pearson model sums to zero.
    ///
    /// `mu_cg = n_c * p_g` divides by the grand total, so a block of pure
    /// zeros has no model at all rather than a degenerate one.
    #[cfg(feature = "single-cell")]
    #[error("Analytic Pearson residuals: the selected counts sum to zero.")]
    AnalyticPearsonZeroTotal,

    /// A non-positive overdispersion was supplied.
    #[cfg(feature = "single-cell")]
    #[error("Analytic Pearson residuals: theta must be positive, got {theta}.")]
    AnalyticPearsonInvalidTheta {
        /// The offending value
        theta: f64,
    },

    /// An unusable residual clipping range was supplied.
    ///
    /// `f64::clamp` panics when the bounds are inverted or either is NaN, so
    /// this has to be caught before a rayon worker reaches it.
    #[cfg(feature = "single-cell")]
    #[error("Residual clipping range [{lo}, {hi}] must be finite with lo <= hi.")]
    ResidualInvalidClipRange {
        /// Lower bound supplied
        lo: f64,
        /// Upper bound supplied
        hi: f64,
    },

    /// The selected cells contain a repeated index.
    ///
    /// The readers key their selection by an `IndexSet`, which deduplicates,
    /// while the residual row is sized by the raw selection. A duplicate would
    /// therefore shift every later cell into the wrong slot rather than
    /// erroring.
    #[cfg(feature = "single-cell")]
    #[error("Residual selection: {n_cells} cell(s) selected but only {n_unique} are distinct.")]
    ResidualDuplicateCells {
        /// Distinct cells in the selection
        n_unique: usize,
        /// Cells supplied
        n_cells: usize,
    },

    /// A gene set that has to be ascending is not.
    ///
    /// Gene lookups are binary searches, so an unsorted axis silently returns
    /// the wrong position rather than failing.
    #[cfg(feature = "single-cell")]
    #[error("Residual model: '{name}' must be strictly ascending.")]
    ResidualGenesNotAscending {
        /// What was out of order
        name: &'static str,
    },

    /// The group labels imply a different number of groups than there are
    /// models.
    #[cfg(feature = "single-cell")]
    #[error("Residual grouping: labels imply {implied} group(s) but {models} model(s) were given.")]
    ResidualGroupModelCountMismatch {
        /// Groups the labels imply
        implied: usize,
        /// Models supplied
        models: usize,
    },

    /// A group label is outside the range the models cover.
    #[cfg(feature = "single-cell")]
    #[error("Residual grouping: label {label} is outside the {n_groups} group(s) available.")]
    ResidualGroupLabelOutOfRange {
        /// The offending label
        label: usize,
        /// Groups available
        n_groups: usize,
    },

    /// A residual variance came out non-finite.
    ///
    /// Always an upstream problem, typically a model deserialised with a zero
    /// or missing scalar. Reported rather than floored, because a floored NaN
    /// silently ranks the gene last in feature selection.
    #[cfg(feature = "single-cell")]
    #[error("Residual variance for gene {gene} is not finite ({value}).")]
    ResidualNonFiniteVariance {
        /// Store index of the gene
        gene: usize,
        /// The offending value
        value: f64,
    },

    /// The residual row's counts and indices disagree in length.
    #[cfg(feature = "single-cell")]
    #[error("Residual row: {n_counts} count(s) for {n_indices} cell index/indices.")]
    ResidualRowLengthMismatch {
        /// Counts supplied
        n_counts: usize,
        /// Indices supplied
        n_indices: usize,
    },

    /// A residual row's cell index points outside the selected cells.
    #[cfg(feature = "single-cell")]
    #[error("Residual row: cell index {index} is outside the {n_cells} selected cell(s).")]
    ResidualCellIndexOutOfRange {
        /// The offending index
        index: usize,
        /// Cells selected
        n_cells: usize,
    },

    /// Feature selection was asked for no genes, or handed no groups.
    #[cfg(feature = "single-cell")]
    #[error("Residual feature selection: {what}.")]
    ResidualEmptySelectionRequest {
        /// What was empty
        what: &'static str,
    },

    /// The overdispersion search failed at every point it evaluated.
    ///
    /// Means the adjusted profile likelihood could not be computed anywhere in
    /// the bracket, typically a design that is rank-deficient for this gene's
    /// weights. The search would otherwise have minimised a constant and
    /// returned a fit built on a meaningless dispersion.
    #[cfg(feature = "single-cell")]
    #[error("scTransform: the dispersion search failed at every point: {reason}")]
    SctDispersionSearchFailed {
        /// The first underlying error, rendered
        reason: String,
    },

    /// One group's fit failed.
    ///
    /// Wraps the underlying error so that a failure in group 7 of 8 is not
    /// indistinguishable from one in group 0. The usual cause is a single
    /// shallow sample where no gene clears `min_cells`.
    #[cfg(feature = "single-cell")]
    #[error("Residual grouping: group {group} failed to fit: {reason}")]
    ResidualGroupFitFailed {
        /// The group that failed
        group: usize,
        /// The underlying error, rendered
        reason: String,
    },

    /// The covariates supplied are not the ones the model was fitted with.
    ///
    /// Only the count is cheap to check, and the count matching is exactly the
    /// case where a reordered data frame goes unnoticed, so the names are
    /// compared too.
    #[cfg(feature = "single-cell")]
    #[error(
        "scTransform covariate {position}: model was fitted with '{model}' but '{supplied}' was supplied."
    )]
    SctCovariateNameMismatch {
        /// Position in the design, excluding the intercept
        position: usize,
        /// Name the model carries
        model: String,
        /// Name the caller supplied
        supplied: String,
    },

    /// A cell index handed to scTransform is outside the selection.
    #[cfg(feature = "single-cell")]
    #[error("scTransform: cell index {index} is outside the {n_cells} selected cell(s).")]
    SctCellIndexOutOfRange {
        /// The offending index
        index: usize,
        /// Cells selected
        n_cells: usize,
    },

    /// A fitted model carries no design columns at all.
    #[cfg(feature = "single-cell")]
    #[error("scTransform model has n_coef = 0; at least the intercept is required.")]
    SctModelWithoutCoefficients,

    /// Variance normalisation was asked for on the residual PCA path.
    ///
    /// Residuals carry the biological signal as variance, which is the point of
    /// the transformation. Dividing it back out per gene flattens exactly the
    /// ranking the transformation produces, so this is refused rather than
    /// silently honoured.
    #[cfg(feature = "single-cell")]
    #[error(
        "PCA on Pearson residuals cannot normalise the variance; residuals already carry it as signal."
    )]
    PcaResidualsWithVarianceNormalisation,

    // -- cellsweep --
    /// The caller asked for a supplied empty droplet mask but did not give one.
    #[cfg(feature = "single-cell")]
    #[error("CellSweep: The empty droplet mask was not supplied but was requested.")]
    CellSweepEmptyMaskMissing,

    /// The expected cell count cannot be turned into a library size cutoff.
    #[cfg(feature = "single-cell")]
    #[error("CellSweep: Cannot take {expected} expected cells from {barcodes} barcodes.")]
    CellSweepBadExpectedCells {
        /// Number of expected cells requested
        expected: usize,
        /// Number of barcodes available
        barcodes: usize,
    },

    /// The knee detector could not find a cutoff.
    #[cfg(feature = "single-cell")]
    #[error("CellSweep: Cannot locate a knee from {barcodes} barcodes above the minimum count.")]
    CellSweepKneeNotFound {
        /// Number of barcodes that survived the minimum count filter
        barcodes: usize,
    },

    /// A sample has too few empty droplets to estimate an ambient profile.
    #[cfg(feature = "single-cell")]
    #[error(
        "CellSweep: Sample '{sample_id}' has {found} empty droplets, at least {required} are needed to estimate the ambient profile. Ingest with permissive QC cutoffs so the empty droplets survive, or set freeze_ambient_profile = false."
    )]
    CellSweepTooFewEmptyDroplets {
        /// Identifier of the offending sample
        sample_id: String,
        /// Empty droplets found
        found: usize,
        /// Empty droplets required
        required: usize,
    },

    /// A sample carries no annotated barcodes.
    #[cfg(feature = "single-cell")]
    #[error("CellSweep: Sample '{sample_id}' has no annotated barcodes to fit.")]
    CellSweepNoRealCells {
        /// Identifier of the offending sample
        sample_id: String,
    },

    /// A cell-type code points outside the profile matrix.
    #[cfg(feature = "single-cell")]
    #[error(
        "CellSweep: Sample '{sample_id}' has cell-type code {code} but only {n_celltypes} cell types."
    )]
    CellSweepCelltypeOutOfRange {
        /// Identifier of the offending sample
        sample_id: String,
        /// The offending cell-type code
        code: usize,
        /// Number of cell types declared
        n_celltypes: usize,
    },

    /// The label vector does not line up with the barcode vector.
    #[cfg(feature = "single-cell")]
    #[error(
        "CellSweep: Sample '{sample_id}' has {n_cells} real barcodes but {n_labels} cell-type labels."
    )]
    CellSweepLabelLengthMismatch {
        /// Identifier of the offending sample
        sample_id: String,
        /// Number of real barcodes
        n_cells: usize,
        /// Number of labels given
        n_labels: usize,
    },

    /// `freeze_empties = false` is not supported.
    #[cfg(feature = "single-cell")]
    #[error(
        "CellSweep: freeze_empties = false is not supported. The reference gives empty droplets a cell-type component they have no label for, which indexes past the end of the profile matrix and wraps onto the last cell type."
    )]
    CellSweepFreezeEmptiesUnsupported,

    /// The EM produced a non-finite log-likelihood.
    #[cfg(feature = "single-cell")]
    #[error("CellSweep: Sample '{sample_id}' diverged at iteration {iteration}.")]
    CellSweepDiverged {
        /// Identifier of the offending sample
        sample_id: String,
        /// Iteration at which the log-likelihood went non-finite
        iteration: usize,
    },

    // -- wnn --
    /// Error if the modalities do not have the same number of cells
    #[cfg(feature = "multi-modal")]
    #[error("WNN: Both modalities need to have the same cell numbers.")]
    WNNModalitySampleMismatch,

    /// Error if k_nn > knn_range
    #[cfg(feature = "multi-modal")]
    #[error("WNN: k_nn must be <= knn_range")]
    WNNKnnLargerThanKnnRange,

    /// Error if sigma_idx >= knn_range
    #[cfg(feature = "multi-modal")]
    #[error("WNN: sigma_idx must be < knn_range")]
    WNNSigmaIdxOutOfRange,

    /// Error if s_nn > knn_range
    #[cfg(feature = "multi-modal")]
    #[error("WNN: s_nn must be <= knn_range")]
    WNNSnnLargerThanKnnRange,

    /// Error if a modality's kNN row count does not match n_cells
    #[cfg(feature = "multi-modal")]
    #[error("WNN: modality {modality} kNN row count {found} does not match n_cells {expected}")]
    WNNKnnRowCountMismatch {
        /// 0 or 1
        modality: usize,
        /// Expected count
        expected: usize,
        /// Found count
        found: usize,
    },

    /// Error if a modality's kNN distance row count does not match n_cells
    #[cfg(feature = "multi-modal")]
    #[error(
        "WNN: modality {modality} kNN distance row count {found} does not match n_cells {expected}"
    )]
    WNNKnnDistRowCountMismatch {
        /// 0 or 1
        modality: usize,
        /// Expected count
        expected: usize,
        /// Found count
        found: usize,
    },

    /// Error if a modality has fewer than knn_range neighbours per cell
    #[cfg(feature = "multi-modal")]
    #[error(
        "WNN: modality {modality} needs at least {expected} neighbours per cell, found {found}"
    )]
    WNNInsufficientNeighbours {
        /// 0 or 1
        modality: usize,
        /// knn_range
        expected: usize,
        /// Actual neighbour count in row 0
        found: usize,
    },

    // -- gpu --
    /// A device-limit or runtime error from the `cubecl-utils-rs` primitives.
    ///
    /// Covers the cube-count, grid, binding-size and shared-memory guards, plus
    /// buffer reads that fail on the CubeCL server.
    #[cfg(feature = "gpu")]
    #[error("GPU: {0}")]
    CubeclUtils(#[from] cubecl_utils_rs::CubeclUtilsErrors),
    /// A GPU cubecl matrix multiplication error from the cubek crate
    #[cfg(feature = "gpu")]
    #[error("GPU: A matrix multiplication occurred: {0}")]
    GpuMatmul(String),
    /// A single named buffer exceeds the device's per-binding size limit.
    ///
    /// Over-sized bindings are rejected without an error surfacing: the kernel
    /// does no work and returns zeros, so the condition is caught on the host
    /// before the upload instead.
    ///
    /// `GpuTensor`'s own constructors already guard their allocations and
    /// report `CubeclUtils`. This variant is for the host-side pre-checks that
    /// walk a whole named buffer set before a dispatch, where the buffer name
    /// is worth more than a bare byte count.
    #[cfg(feature = "gpu")]
    #[error(
        "GPU: buffer '{buffer}' needs {bytes} bytes, device per-binding limit is {limit} bytes"
    )]
    GpuBindingTooLarge {
        /// Name of the buffer that does not fit.
        buffer: &'static str,
        /// Bytes the buffer requires.
        bytes: usize,
        /// Per-binding limit reported by the device.
        limit: usize,
    },
    /// The requested NMF rank is above the largest comptime register tier the
    /// GPU HALS sweeps are compiled for.
    ///
    /// The sweeps hold a whole row of `W` or column of `H` in a register array
    /// whose length must be known at compilation, so the dispatch picks the
    /// smallest tier that fits `k`. Past the largest tier the array would spill
    /// to global memory and the kernel would be slower than the CPU path, so
    /// this errors rather than silently degrading.
    #[cfg(feature = "gpu")]
    #[error(
        "NMF GPU: rank k = {k} is above the largest supported rank of {max}; use the CPU entry point for ranks this large"
    )]
    GpuNmfRankTooLarge {
        /// Requested number of components.
        k: usize,
        /// Largest rank the compiled tiers cover.
        max: usize,
    },
    // -- gpu / single cell --
    /// GPU Harmony only supports one co-variate for now (based on Arrowhead)
    #[cfg(feature = "gpu")]
    #[error("Harmony GPU: Only a single co-variate is supported for the GPU path")]
    GpuHarmonySupportsSingleCovariateOnly,
    /// Selected SCENIC regression learner has no GPU implementation. Only the
    /// tree-based learners (ExtraTrees, RandomForest) are ported; GRNBoost2 /
    /// gradient boosting stays on CPU by design.
    #[cfg(feature = "gpu")]
    #[error(
        "SCENIC GPU: the {learner} regression learner has no GPU path; use the CPU entry point (run_scenic_grn / run_scenic_grn_streaming / run_scenic_grn_in_memory)"
    )]
    GpuNotSupportedForLearner {
        /// Name of the requested regression learner.
        learner: &'static str,
    },
    /// The device cannot give `build_score_rf_fused` the threadgroup memory it
    /// asks for, so SCENIC's RandomForest learner has no GPU path on it.
    ///
    /// The kernel's ask is a compile-time constant checked against the tightest
    /// shipping backend floor, so reaching this means a device well outside
    /// what the crate targets rather than an unlucky shape.
    #[cfg(feature = "gpu")]
    #[error(
        "SCENIC GPU: the fused RandomForest kernel needs {bytes} bytes of shared memory but this device offers {limit}; use the CPU entry point (run_scenic_grn / run_scenic_grn_streaming / run_scenic_grn_in_memory) or switch the learner to ExtraTrees"
    )]
    GpuScenicFusedRfUnavailable {
        /// Shared-memory bytes the fused kernel allocates per workgroup.
        bytes: usize,
        /// Shared-memory bytes the device reports.
        limit: usize,
    },
}