Skip to main content

bixverse_sc/
errors.rs

1//! Errors raised by `bixverse-sc`.
2
3use thiserror::Error;
4
5/// Error variants raised by `bixverse-sc`.
6///
7/// Grouped by subsystem.
8#[derive(Debug, Error)]
9pub enum ScErrors {
10    // -- external crates --
11    /// Propagates an error from `ann-search-rs`.
12    #[error("Error from the ann-search-rs crate: {0}")]
13    AnnSearchRsError(#[from] ann_search_rs::errors::AnnSearchErrors),
14
15    /// Propagates an error from `edge-rs`.
16    #[error("Error from the edge-rs crate: {0}")]
17    EdgeRsError(#[from] edge_rs::errors::EdgeErrors),
18
19    // -- lower crates --
20    /// Propagates an error from `bixverse-core`.
21    #[error(transparent)]
22    Core(#[from] bixverse_core::errors::CoreErrors),
23
24    /// Propagates an error from `bixverse-methods`.
25    #[error(transparent)]
26    Methods(#[from] bixverse_methods::errors::MethodsErrors),
27
28    /// Propagates an error from `bixverse-sc-data`.
29    #[error(transparent)]
30    ScData(#[from] bixverse_sc_data::errors::ScDataErrors),
31
32    // -- bonsai-rs --
33    /// Propagate errors from the bonsai-rs crate
34    #[cfg(feature = "bonsai")]
35    #[error("Error from the bonsai-rs crate: {0}")]
36    BonsaiRsError(#[from] bonsai_rs::errors::BonsaiErrors),
37
38    /// Propagate errors from the sanity-sc-rs crate, as re-exported by
39    /// bonsai-rs
40    #[cfg(feature = "bonsai")]
41    #[error("Error from the sanity-sc-rs crate: {0}")]
42    SanityRsError(#[from] bonsai_rs::sanity_sc_rs::errors::SanityErrors),
43
44    /// Most of the supplied kNN indices point outside the cell set
45    ///
46    /// The usual cause is a kNN computed against a reference index rather than
47    /// the query set, which drops every edge and returns an all-zero
48    /// connectivity matrix looking like a genuine result.
49    #[error(
50        "PAGA: {out_of_range} of {total} kNN entries point outside the {n_cells} cells; the graph was probably built against a different index"
51    )]
52    PagaKnnIndicesOutOfRange {
53        /// Entries pointing past the last cell
54        out_of_range: usize,
55        /// Entries supplied in total
56        total: usize,
57        /// Cells in the input
58        n_cells: usize,
59    },
60
61    /// Two stores that must share a gene axis disagree on how many genes
62    /// they hold.
63    ///
64    /// Raised where a method takes a gene-major and a cell-major reader over
65    /// what is meant to be the same experiment. Nothing ties the two files
66    /// together, so the check has to be explicit: indices derived from one
67    /// store are used against buffers sized from the other.
68    #[error(
69        "The gene store holds {gene_store} genes but the cell store holds {cell_store}; they must describe the same experiment"
70    )]
71    GeneAxisMismatch {
72        /// Total genes reported by the gene-major store
73        gene_store: usize,
74        /// Total genes reported by the cell-major store
75        cell_store: usize,
76    },
77    // -- Batch --
78    /// Need at least two batches for this method
79    #[error("You need at least two batches. Provided {n_batches} batches.")]
80    NeedAtLeastTwoBatches {
81        /// Number of provided batches
82        n_batches: usize,
83    },
84
85    // -- Integration metrics --
86    /// Need at least two distinct labels for this metric
87    #[error("You need at least two distinct labels. Provided {n_labels} labels.")]
88    NeedAtLeastTwoLabels {
89        /// Number of distinct labels found
90        n_labels: usize,
91    },
92
93    /// The label vector does not line up with the number of cells
94    #[error("Expected {n_cells} labels, one per cell; got {n_labels}.")]
95    MetricLabelLengthMismatch {
96        /// Number of cells in the embedding or kNN graph
97        n_cells: usize,
98        /// Length of the provided label vector
99        n_labels: usize,
100    },
101
102    /// A batch holds too few cells for the requested per-batch neighbour count
103    #[error("BBKNN: batch {batch} has {n_cells} cells, needs at least {required}.")]
104    BbknnBatchTooSmall {
105        /// Batch label
106        batch: usize,
107        /// Cells in the batch
108        n_cells: usize,
109        /// Minimum required, i.e. `neighbours_within_batch + 1`
110        required: usize,
111    },
112
113    /// A batch has too few cells for the requested anchor search
114    #[error("Batch {batch} has {n_cells} cells, needs at least {required} for anchor finding.")]
115    TooFewCellsForAnchor {
116        /// Batch index
117        batch: usize,
118        /// Cells in the batch
119        n_cells: usize,
120        /// Minimum required
121        required: usize,
122    },
123    // -- Harmony --
124    /// Sigma length is not equal to the number of clusters
125    #[error("Harmony: sigma length must match number of clusters")]
126    HarmonySigmaLengthUnequalCluster,
127
128    // -- PCA --
129    /// Error if user wants CLR-normalised PCA, but did not provide the offsets
130    #[error(
131        "You did not provide the offsets needed for using the CLR-normalised PCA in single cell."
132    )]
133    OffsetsNotProvidedForClrPCA,
134
135    /// Error if user wants CLR-normalised PCA, but did not provide the offsets
136    #[error(
137        "The provided offsets have length {len_offset}; n_cells is {n_cells}. Length mismatch."
138    )]
139    OffsetsLengthDoesNotMatchNCells {
140        /// Length of the offsets
141        len_offset: usize,
142        /// Number of cells provided
143        n_cells: usize,
144    },
145
146    // -- HVG --
147    /// A requested cell index does not exist in the store.
148    #[error("HVG: cell index {index} is outside the store, which holds {total_cells} cells.")]
149    HvgCellIndexOutOfRange {
150        /// The offending cell index
151        index: usize,
152        /// Number of cells the store holds
153        total_cells: usize,
154    },
155
156    /// The same cell was selected twice, which would inflate the denominator.
157    #[error("HVG: cell index {index} appears more than once in the selection.")]
158    HvgDuplicateCellIndex {
159        /// The duplicated cell index
160        index: usize,
161    },
162
163    /// Batch labels and cell indices disagree in length.
164    #[error("HVG: {n_labels} batch labels were given for {n_cells} selected cells.")]
165    HvgBatchLabelLengthMismatch {
166        /// Number of batch labels provided
167        n_labels: usize,
168        /// Number of selected cells
169        n_cells: usize,
170    },
171
172    /// A batch in `0..n_batches` ended up with no cells.
173    #[error("HVG: batch {batch} has no cells; labels must densely cover 0..{n_batches}.")]
174    HvgEmptyBatch {
175        /// The empty batch
176        batch: usize,
177        /// Number of batches implied by the labels
178        n_batches: usize,
179    },
180
181    /// No cells were selected at all.
182    #[error("HVG: the cell selection is empty.")]
183    HvgEmptySelection,
184
185    /// Loess span outside the range [`crate::core::base::loess::LoessRegression`]
186    /// accepts.
187    #[error("HVG: the loess span is {span}, but it must be within (0, 1].")]
188    HvgInvalidLoessSpan {
189        /// The provided span
190        span: f32,
191    },
192
193    /// Bin count of zero, which would divide by zero during binning.
194    #[error("HVG: n_bins must be at least 1.")]
195    HvgInvalidBinCount,
196
197    /// Too few genes pass the mean filter to fit the scran variance trend.
198    #[error(
199        "HVG: only {n_kept} genes have a mean log-expression of at least {minimum_mean}; the variance trend needs two."
200    )]
201    HvgTooFewTrendPoints {
202        /// Genes left after the mean filter
203        n_kept: usize,
204        /// The mean filter threshold
205        minimum_mean: f64,
206    },
207
208    // -- Hotspot --
209    /// Invalid model chosen for Hotspot
210    #[error("Invalid model type: {0}")]
211    HotSpotWrongModel(String),
212
213    // -- Metacells2 --
214    /// User needs to have generated select_features prior to the
215    /// compute_similarity part.
216    #[error("select_features must be called before compute_similarity!")]
217    SelectFeaturesBeforeSimilariy,
218
219    /// Feature selection kept too few genes to correlate cells on.
220    #[error("MetaCells2: selected {selected} feature genes, need at least {required}")]
221    Mc2TooFewGenes {
222        /// Genes that passed the filters
223        selected: usize,
224        /// Genes required
225        required: usize,
226    },
227
228    /// A per-gene or per-cell vector does not match the pile.
229    #[error("MetaCells2: {what} has length {got}, expected {expected}")]
230    Mc2LengthMismatch {
231        /// Which input is off
232        what: &'static str,
233        /// Expected length
234        expected: usize,
235        /// Supplied length
236        got: usize,
237    },
238
239    /// The pile holds no cells.
240    #[error("MetaCells2: the pile is empty")]
241    Mc2EmptyPile,
242
243    /// Parameters that cannot produce a valid run.
244    #[error("MetaCells2: invalid parameters: {reason}")]
245    Mc2InvalidParams {
246        /// What is wrong
247        reason: String,
248    },
249
250    /// Seed completion found nodes that no seed can reach.
251    #[error("MetaCells2: {n_unreachable} cells are not connected to any seed")]
252    Mc2UnreachableCells {
253        /// Cells left without a seed
254        n_unreachable: usize,
255    },
256
257    // -- Seacells --
258    /// The SEACells kernel matrix has not been yet constructed.
259    #[error("SEACells: You must construct the kernel matrix first.")]
260    SEACellsKernelMatrixMissing,
261
262    /// The SEACells archetypes have not yet been identified.
263    #[error("SEACells: You must first initialise the Archetypes.")]
264    SEACellsArchetypesMissing,
265
266    /// The SEACells archetypes have not yet been identified.
267    #[error("SEACells: The model has not been fitted yet. Please run .fit()")]
268    SEACellsModelNotFitted,
269
270    /// The adaptive diffusion kernel came out with non-finite weights.
271    ///
272    /// Raised by
273    /// [bixverse_sc::mc_generation::seacells::compute_diffusion_kernel].
274    /// The usual cause is a caller-supplied kNN graph carrying `NaN` distances,
275    /// since zero bandwidths are handled by the smallest-positive fallback.
276    #[error(
277        "The adaptive diffusion kernel over {n_cells} cells holds non-finite weights; check the supplied kNN distances"
278    )]
279    DiffusionKernelNotFinite {
280        /// Cells in the kNN graph
281        n_cells: usize,
282    },
283
284    // -- MELD --
285    /// If the user provides a label which is out-of-range of the expected
286    /// number of groups
287    #[error("MELD: label {label} out of range for n_groups={n_groups}.")]
288    MELDLabelOutOfRange {
289        /// The label which is out of range
290        label: usize,
291        /// The number of expected groups
292        n_groups: usize,
293    },
294
295    /// If the user asks for less than two Chebyshev coefficients
296    #[error("MELD: Need at least 2 Chebyshev coefficients")]
297    MELDChebyshevCoefTooLow,
298
299    /// Error if labels and number of cells are not equal
300    #[error("MELD: The labels are not equal to the number of cells")]
301    MELDLabelUnequalsSamples,
302
303    /// Error of n_groups < 2
304    #[error("MELD: labels needs two groups minimum")]
305    MELDOnlyOneGroup,
306
307    // -- DGE --
308    /// Fewer than two cell groups, so there is nothing to compare.
309    #[error("DGE: at least two cell groups are required, got {n_groups}")]
310    DgeTooFewGroups {
311        /// Number of groups supplied
312        n_groups: usize,
313    },
314
315    /// More cell groups than the `u16` group labels can hold.
316    #[error("DGE: {n_groups} cell groups exceed the maximum of {max}")]
317    DgeTooManyGroups {
318        /// Number of groups supplied
319        n_groups: usize,
320        /// Largest supported number of groups
321        max: usize,
322    },
323
324    /// One of the cell groups holds no cells.
325    #[error("DGE: cell group {group} is empty")]
326    DgeEmptyGroup {
327        /// Index of the offending group
328        group: usize,
329    },
330
331    /// A cell index beyond the end of the store.
332    #[error("DGE: cell {cell} is out of range for a store of {n_cells} cells")]
333    DgeCellOutOfRange {
334        /// The offending cell index
335        cell: usize,
336        /// Total cells in the store
337        n_cells: usize,
338    },
339
340    /// A cell appears in two groups, or twice in one.
341    ///
342    /// Silently biases every AUROC toward 0.5 rather than failing, so it is
343    /// worth rejecting outright.
344    #[error("DGE: cell {cell} appears in cell group {first} and again in group {second}")]
345    DgeOverlappingGroups {
346        /// The cell listed more than once
347        cell: usize,
348        /// Group the cell was first seen in
349        first: usize,
350        /// Group the cell was seen in again
351        second: usize,
352    },
353
354    /// A reference arm index beyond the number of groups.
355    #[error("DGE: reference {reference} is out of range for {n_groups} cell groups")]
356    DgeReferenceOutOfRange {
357        /// The offending reference index
358        reference: usize,
359        /// Number of groups supplied
360        n_groups: usize,
361    },
362
363    // -- NEBULA --
364    /// Every gene failed NEBULA's own expression filter.
365    ///
366    /// Raised only once the whole sweep is done. A single batch losing all of
367    /// its genes is normal and is skipped silently.
368    #[error(
369        "NEBULA: none of the {n_genes} genes passed the expression filter; lower `cpc` or `mincp`"
370    )]
371    NebulaNoGenesKept {
372        /// How many genes were offered
373        n_genes: usize,
374    },
375
376    // -- Palantir --
377    /// The user-supplied early cell index sits outside the cell range.
378    #[error("Palantir: early cell index {early_cell} is out of range for {n_cells} cells")]
379    PalantirEarlyCellOutOfRange {
380        /// The offending early cell index
381        early_cell: usize,
382        /// Number of cells in the input
383        n_cells: usize,
384    },
385
386    /// A user-supplied terminal state index sits outside the cell range.
387    #[error("Palantir: terminal state index {state} is out of range for {n_cells} cells")]
388    PalantirTerminalStateOutOfRange {
389        /// The offending terminal state index
390        state: usize,
391        /// Number of cells in the input
392        n_cells: usize,
393    },
394
395    /// A terminal state cell was never sampled as a waypoint, so it has no
396    /// position in the waypoint Markov chain.
397    #[error(
398        "Palantir: terminal state cell {state} is not among the {n_waypoints} sampled waypoints"
399    )]
400    PalantirTerminalStateNotAWaypoint {
401        /// The offending terminal state cell index
402        state: usize,
403        /// Number of sampled waypoints
404        n_waypoints: usize,
405    },
406
407    /// The first waypoint is not the start cell, which every consumer of the
408    /// geodesic matrix assumes.
409    #[error(
410        "Palantir: waypoint 0 is cell {first_waypoint}, but the start cell is {start_cell}; the start cell must be the first waypoint"
411    )]
412    PalantirStartCellNotFirstWaypoint {
413        /// The start cell the caller asked for
414        start_cell: usize,
415        /// The cell actually sitting in waypoint position zero
416        first_waypoint: usize,
417    },
418
419    /// The pseudotime refinement cap leaves no room for a single pass.
420    #[error(
421        "Palantir: max_iterations is {max_iterations}, but at least {minimum} is needed to run a refinement pass"
422    )]
423    PalantirMaxIterationsTooSmall {
424        /// The requested cap
425        max_iterations: usize,
426        /// Smallest cap that runs at least one pass
427        minimum: usize,
428    },
429
430    /// The neighbour count is too small to derive the back-edge bandwidth of
431    /// the waypoint Markov chain, which indexes neighbour rank
432    /// `min(knn / 3 - 1, 30)`.
433    ///
434    /// Carries the neighbour count the caller asked for, not the one clamped to
435    /// the waypoint set. Too few waypoints raises
436    /// [BixverseErrors::PalantirTooFewWaypoints] instead, so the message always
437    /// names the parameter that can actually be changed.
438    #[error("Palantir: knn is {knn}, but at least {minimum} is needed for the adaptive bandwidth")]
439    PalantirKnnTooSmall {
440        /// The requested neighbour count
441        knn: usize,
442        /// Smallest neighbour count yielding a valid bandwidth rank
443        minimum: usize,
444    },
445
446    /// Too few waypoints were sampled to support the back-edge bandwidth rank,
447    /// however large the requested `knn` is.
448    #[error(
449        "Palantir: only {n_waypoints} waypoints were sampled, but at least {minimum} are needed for the adaptive bandwidth; raise num_waypoints or supply more cells"
450    )]
451    PalantirTooFewWaypoints {
452        /// Waypoints actually sampled
453        n_waypoints: usize,
454        /// Smallest waypoint count yielding a valid bandwidth rank
455        minimum: usize,
456    },
457
458    /// The waypoint data and the waypoint pseudotime disagree on the waypoint
459    /// count.
460    #[error(
461        "Palantir: {n_waypoints} waypoint rows were supplied against {n_pseudotime} pseudotime values"
462    )]
463    PalantirWaypointDataMismatch {
464        /// Rows of multiscale data restricted to the waypoints
465        n_waypoints: usize,
466        /// Pseudotime values supplied alongside
467        n_pseudotime: usize,
468    },
469
470    /// The stationary ranks have no spread, so the outlier cutoff separates
471    /// nothing.
472    ///
473    /// More than half the waypoints share a stationary mass, which puts the
474    /// median absolute deviation at exactly zero and the cutoff exactly on the
475    /// median. Roughly half of everything then clears it, the components merge,
476    /// and the branching structure collapses to a single terminal state.
477    #[error(
478        "Palantir: the stationary ranks over {n_waypoints} waypoints have zero spread, so terminal states cannot be separated; supply them explicitly or raise knn"
479    )]
480    PalantirStationaryRanksDegenerate {
481        /// Waypoints in the chain
482        n_waypoints: usize,
483    },
484
485    /// Cells remained unreachable from the start cell after graph repair.
486    #[error(
487        "Palantir: {n_unreachable} cells remain unreachable from the start cell after {repairs} repair edges; increase knn"
488    )]
489    PalantirDisconnectedGraph {
490        /// Cells still at infinite geodesic distance
491        n_unreachable: usize,
492        /// Repair edges added before giving up
493        repairs: usize,
494    },
495
496    /// The pseudotime collapsed to a constant, or the Silverman bandwidth was
497    /// zero or non-finite, so the refinement cannot be normalised.
498    #[error("Palantir: degenerate pseudotime ({reason}); the manifold is probably collapsed")]
499    PalantirDegeneratePseudotime {
500        /// Which quantity degenerated
501        reason: &'static str,
502    },
503
504    /// The stationary power iteration lost all of its mass, which can only
505    /// happen when the supplied chain is not row-stochastic or carries
506    /// non-finite entries.
507    #[error(
508        "Palantir: the stationary power iteration lost its mass at iteration {iteration} (total mass {total:e}); the transition matrix is not row-stochastic"
509    )]
510    PalantirStationaryMassLost {
511        /// The pass on which the mass went non-positive or non-finite
512        iteration: usize,
513        /// The offending total mass
514        total: f64,
515    },
516
517    /// An eigenvalue of the diffusion operator came back non-finite.
518    ///
519    /// Clamping it would fabricate a plausible-looking scale factor out of a
520    /// `NaN`, since `f64::min` returns the other operand for a `NaN` input.
521    #[error(
522        "Palantir: the diffusion eigensolve returned a non-finite eigenvalue ({eigenvalue}); the kernel is degenerate"
523    )]
524    PalantirNonFiniteEigenvalue {
525        /// The offending eigenvalue
526        eigenvalue: f64,
527    },
528
529    /// An explicit `n_eigs` below the smallest usable count was requested.
530    ///
531    /// The trivial leading eigenvector is always dropped, so fewer than two
532    /// eigenvectors leaves no multiscale coordinate at all. The request is
533    /// refused rather than raised to the heuristic's floor: silently changing a
534    /// pinned count changes every distance downstream.
535    #[error(
536        "Palantir: n_eigs is {n_eigs}, but at least {minimum} eigenvectors are needed for a multiscale space"
537    )]
538    PalantirNEigsTooSmall {
539        /// The requested eigenvector count
540        n_eigs: usize,
541        /// Smallest count leaving a usable coordinate
542        minimum: usize,
543    },
544
545    /// No terminal states survived the rank cutoff and the caller supplied none.
546    #[error(
547        "Palantir: no terminal states detected; supply them explicitly or increase num_waypoints"
548    )]
549    PalantirNoTerminalStates,
550
551    /// The multiscale space has no boundary cells, so there is nothing to snap
552    /// the start cell or a terminal-state candidate onto.
553    ///
554    /// Boundaries are the per-component argmin and argmax, so an empty set means
555    /// the multiscale space has no columns at all.
556    #[error("Palantir: the multiscale space has no boundary cells to snap onto")]
557    PalantirNoBoundaryCells,
558
559    /// Cells remained unreachable from the waypoint set when the geodesic
560    /// matrix was built.
561    ///
562    /// Distinct from [BixverseErrors::PalantirDisconnectedGraph], which reports
563    /// the repair attempts. This fires after repair, inside the pseudotime
564    /// solve, where the repair count is not in scope and claiming zero of them
565    /// would be a lie.
566    #[error("Palantir: {n_unreachable} cells have no geodesic path to some waypoint; increase knn")]
567    PalantirUnreachableFromWaypoints {
568        /// Cells at infinite geodesic distance from at least one waypoint
569        n_unreachable: usize,
570    },
571
572    /// The densified `(I - Q) B = R` solve did not converge.
573    ///
574    /// Carries the achieved `‖(I - Q) B - R‖_inf`, which is what actually
575    /// distinguishes a near-singular system from ordinary rounding.
576    #[error(
577        "Palantir: the (I - Q) B = R solve over {n_transient} transient waypoints left a residual of {residual:e}, above tolerance"
578    )]
579    PalantirAbsorbingSolveFailed {
580        /// Achieved `‖(I - Q) B - R‖_inf`
581        residual: f64,
582        /// Total transient waypoints
583        n_transient: usize,
584    },
585
586    /// The absorption probabilities of one waypoint sum to more than one.
587    ///
588    /// Split out from [BixverseErrors::PalantirAbsorbingSolveFailed] because the
589    /// solve residual says nothing here: the factorisation can be exact to
590    /// machine precision and still land on this, which is what makes the
591    /// offending sum the only number worth reporting.
592    #[error(
593        "Palantir: waypoint {waypoint} absorbs with probability {row_sum} across {n_transient} transient waypoints, which is above one"
594    )]
595    PalantirAbsorbingRowSum {
596        /// The offending row sum
597        row_sum: f64,
598        /// Waypoint index the row belongs to
599        waypoint: usize,
600        /// Total transient waypoints
601        n_transient: usize,
602    },
603
604    /// The absorption probabilities of one waypoint are not finite.
605    ///
606    /// Split from [BixverseErrors::PalantirAbsorbingRowSum] because the two
607    /// point at different bugs: a non-finite sum means a `NaN` reached the
608    /// solve, while a sum above one means the chain was not sub-stochastic.
609    #[error(
610        "Palantir: waypoint {waypoint} has a non-finite absorption row sum ({row_sum}) across {n_transient} transient waypoints; the solve produced non-finite entries"
611    )]
612    PalantirAbsorbingRowNotFinite {
613        /// The offending row sum
614        row_sum: f64,
615        /// Waypoint index the row belongs to
616        waypoint: usize,
617        /// Total transient waypoints
618        n_transient: usize,
619    },
620
621    /// The densified `(I - Q)` would exceed the supported size.
622    #[error(
623        "Palantir: {n_transient} transient waypoints exceed the dense solve limit of {limit}; lower num_waypoints"
624    )]
625    PalantirTooManyWaypointsForDenseSolve {
626        /// Transient waypoint count requested
627        n_transient: usize,
628        /// The supported ceiling
629        limit: usize,
630    },
631
632    // -- MAGIC --
633    /// The requested imputation would not fit in a sane amount of memory.
634    ///
635    /// MAGIC output is unavoidably dense, so the only bound available is the
636    /// caller's gene selection. This is an error rather than a warning because
637    /// the crate is driven from R, where a printed warning is easily missed and
638    /// the alternative failure mode is the session being OOM-killed.
639    #[error(
640        "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",
641        (*n_cells as f64 * *n_genes as f64 * 4.0 / 1.048576e6).round(),
642        (*max_elements as f64 * 4.0 / 1.048576e6).round()
643    )]
644    MagicOutputTooLarge {
645        /// Cells in the selection
646        n_cells: usize,
647        /// Genes in the selection
648        n_genes: usize,
649        /// The element ceiling that was exceeded
650        max_elements: usize,
651    },
652
653    /// The kNN inputs disagree with the requested cell selection.
654    #[error(
655        "MAGIC: the kNN graph has {n_knn} rows but {n_selected} cells were selected; the two must be aligned"
656    )]
657    MagicKnnSelectionMismatch {
658        /// Rows in the supplied kNN graph
659        n_knn: usize,
660        /// Cells in the selection
661        n_selected: usize,
662    },
663
664    /// A cell index in the selection sits outside the store.
665    #[error("MAGIC: cell index {cell} is out of range for a store of {total_cells} cells")]
666    MagicCellOutOfRange {
667        /// The offending cell index
668        cell: usize,
669        /// Cells in the store
670        total_cells: usize,
671    },
672
673    /// A cell index appears twice in the selection.
674    #[error("MAGIC: cell index {cell} appears more than once in the selection")]
675    MagicDuplicateCell {
676        /// The duplicated cell index
677        cell: usize,
678    },
679
680    /// The operator was built against a different store than the one being read.
681    ///
682    /// The operator's cell lookup is indexed by global cell id, so reading a
683    /// store with more cells than it was sized for would index past its end.
684    #[error(
685        "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"
686    )]
687    MagicStoreSizeMismatch {
688        /// Cells the operator was built for
689        operator_cells: usize,
690        /// Cells the reader actually holds
691        store_cells: usize,
692    },
693
694    // -- Gene trends --
695    /// The expression matrix does not have one row per cell.
696    #[error(
697        "Gene trends: the expression matrix has {n_cells} rows but {n_pseudotime} pseudotime values were supplied"
698    )]
699    GeneTrendsShapeMismatch {
700        /// Rows in the expression matrix
701        n_cells: usize,
702        /// Length of the pseudotime vector
703        n_pseudotime: usize,
704    },
705
706    /// A branch selected no cells at all.
707    #[error("Gene trends: branch {branch} selected no cells")]
708    GeneTrendsBranchEmpty {
709        /// Index of the offending branch
710        branch: usize,
711    },
712
713    /// A branch has too few cells to fit anything meaningful.
714    ///
715    /// The reference has no such guard and will happily fit a 500-point grid to
716    /// a single cell. The resulting curve is the prior, not the data.
717    #[error(
718        "Gene trends: branch {branch} has only {n_cells} cells, which is below the minimum of {minimum}"
719    )]
720    GeneTrendsBranchTooFewCells {
721        /// Index of the offending branch
722        branch: usize,
723        /// Cells the branch selected
724        n_cells: usize,
725        /// Minimum the fit requires
726        minimum: usize,
727    },
728
729    /// Every cell in a branch shares the same pseudotime, so there is no grid
730    /// to fit over.
731    #[error("Gene trends: branch {branch} spans zero pseudotime, so its grid would be degenerate")]
732    GeneTrendsDegeneratePseudotime {
733        /// Index of the offending branch
734        branch: usize,
735    },
736
737    /// Fate-probability weighting was asked for without the probabilities.
738    #[error(
739        "Gene trends: BranchWeighting::FateProbability needs the fate probabilities, but none were supplied"
740    )]
741    GeneTrendsMissingFateProbabilities,
742
743    /// The fate-probability matrix does not have one column per branch.
744    ///
745    /// Branch `i` takes column `i` positionally, so a mismatch would silently
746    /// weight every cell by the wrong fate rather than failing.
747    #[error(
748        "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"
749    )]
750    GeneTrendsFateColumnMismatch {
751        /// Columns in the fate probability matrix
752        n_columns: usize,
753        /// Branches supplied
754        n_branches: usize,
755    },
756
757    /// The fate-probability matrix does not have one row per cell.
758    #[error(
759        "Gene trends: the fate probabilities have {n_rows} rows but {n_cells} cells were supplied"
760    )]
761    GeneTrendsFateShapeMismatch {
762        /// Rows in the fate probability matrix
763        n_rows: usize,
764        /// Cells supplied
765        n_cells: usize,
766    },
767
768    /// A branch cell index sits outside the cell range.
769    #[error(
770        "Gene trends: branch {branch} references cell {cell}, which is out of range for {n_cells} cells"
771    )]
772    GeneTrendsCellOutOfRange {
773        /// Index of the offending branch
774        branch: usize,
775        /// The offending cell index
776        cell: usize,
777        /// Cells in the input
778        n_cells: usize,
779    },
780
781    /// Every cell in a branch carries zero fate-probability weight.
782    ///
783    /// The Gaussian process treats a zero-weight observation as absent, so this
784    /// would otherwise return the zero-mean prior and be indistinguishable from
785    /// a genuinely flat gene.
786    #[error(
787        "Gene trends: every cell selected for branch {branch} has zero fate probability, so the fit would carry no data"
788    )]
789    GeneTrendsZeroWeightBranch {
790        /// Index of the offending branch
791        branch: usize,
792    },
793
794    /// The requested grid is too coarse to fit anything over.
795    #[error("Gene trends: a resolution of {resolution} is below the minimum of {minimum}")]
796    GeneTrendsResolutionTooLow {
797        /// Requested grid points
798        resolution: usize,
799        /// Minimum the fit requires
800        minimum: usize,
801    },
802
803    // -- DIALOGUE --
804    /// Fewer than two cell types were supplied.
805    ///
806    /// There is nothing to correlate across with one.
807    #[error("DIALOGUE: needs at least two cell types; got {n_cell_types}.")]
808    DialogueTooFewCellTypes {
809        /// Cell types supplied
810        n_cell_types: usize,
811    },
812
813    /// Too few samples are represented in every cell type.
814    ///
815    /// The decomposition runs on the samples the cell types share, and that
816    /// intersection is what has come up short.
817    #[error(
818        "DIALOGUE: only {found} samples are present in every cell type; at least {minimum} are needed."
819    )]
820    DialogueTooFewSharedSamples {
821        /// Samples in the intersection
822        found: usize,
823        /// Minimum the decomposition requires
824        minimum: usize,
825    },
826
827    /// Too few features in one cell type survived the ANOVA filter.
828    ///
829    /// A feature is kept when it varies across samples. If almost none do, the
830    /// cell type carries no sample-level signal to decompose, and the usual
831    /// fixes are to drop it from the run or to set the spatial flag.
832    #[error(
833        "DIALOGUE: only {found} of {total} features in cell type {cell_type} passed the ANOVA filter; at least {minimum} are needed."
834    )]
835    DialogueTooFewFeatures {
836        /// Index of the offending cell type
837        cell_type: usize,
838        /// Features that passed
839        found: usize,
840        /// Features tested
841        total: usize,
842        /// Minimum the decomposition requires
843        minimum: usize,
844    },
845
846    /// Too few samples carry enough cells for the ANOVA filter to run.
847    #[error(
848        "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."
849    )]
850    DialogueTooFewAbundantSamples {
851        /// Index of the offending cell type
852        cell_type: usize,
853        /// Samples meeting the threshold
854        found: usize,
855        /// Cells a sample must contribute
856        min_cells: usize,
857    },
858
859    // -- CellPhoneDB --
860    /// A partner subunit points outside the genes in the store.
861    #[error("CellPhoneDB: gene index {index} is outside the {n_genes} genes in the store.")]
862    CpdbGeneIndexOutOfRange {
863        /// The offending index
864        index: usize,
865        /// Number of genes in the store
866        n_genes: usize,
867    },
868
869    /// A cluster lists a cell outside the store.
870    #[error("CellPhoneDB: cell index {index} is outside the {n_cells} cells in the store.")]
871    CpdbCellIndexOutOfRange {
872        /// The offending index
873        index: usize,
874        /// Number of cells in the store
875        n_cells: usize,
876    },
877
878    /// An interaction has a partner without any subunit genes.
879    #[error("CellPhoneDB: interaction {interaction} has a partner without genes.")]
880    CpdbEmptyPartner {
881        /// Index of the offending interaction
882        interaction: usize,
883    },
884
885    /// A cell was assigned to more than one cluster.
886    ///
887    /// The permutation shuffles one label per cell, so clusters must be
888    /// disjoint.
889    #[error("CellPhoneDB: cell {cell} appears in more than one cluster.")]
890    CpdbClusterOverlap {
891        /// The offending cell index
892        cell: usize,
893    },
894
895    // -- sctype --
896    /// Error when number of cluster assignment != the number of cells
897    #[error(
898        "SCType: The number of cells ({n_cells}) and cluster assignments length ({n_cluster_assignment}) is not the same."
899    )]
900    ScTypeClusterAssignmentNotEqualNCells {
901        /// Number of cells
902        n_cells: usize,
903        /// Number of cluster assignments
904        n_cluster_assignment: usize,
905    },
906
907    /// Error when the smoothing graph node count != the number of cells
908    #[error("SCType: The graph has {n_nodes} nodes, but there are {n_cells} cells.")]
909    ScTypeGraphNodesNotEqualNCells {
910        /// Number of cells
911        n_cells: usize,
912        /// Number of nodes in the smoothing graph
913        n_nodes: usize,
914    },
915
916    // -- scTransform --
917    /// A step-1 gene index pointed outside the modelled gene set.
918    #[error("scTransform: step-1 gene index {index} is outside the {n_genes} modelled genes.")]
919    SctGeneIndexOutOfRange {
920        /// The offending index
921        index: usize,
922        /// Number of modelled genes
923        n_genes: usize,
924    },
925
926    /// Too few step-1 genes survived outlier and Poisson exclusion to smooth.
927    ///
928    /// Two is the bare minimum a bandwidth can be estimated from. In practice
929    /// this means the subsample was dominated by Poisson genes, which is what a
930    /// very shallow dataset looks like.
931    #[error(
932        "scTransform: only {kept} of {total} step-1 genes survived exclusion, too few to regularise."
933    )]
934    SctTooFewGenesToRegularise {
935        /// Genes left after exclusion
936        kept: usize,
937        /// Genes the step-1 fit started from
938        total: usize,
939    },
940
941    /// The kernel smoothing produced no value for a gene.
942    ///
943    /// Means no retained step-1 gene fell inside the kernel support at that
944    /// gene's abundance, which a bandwidth this wide should make impossible.
945    #[error("scTransform: regularisation left gene {gene} without a fitted parameter.")]
946    SctSmoothingLeftAGeneUnfitted {
947        /// Position of the gene in the modelled set
948        gene: usize,
949    },
950
951    /// No gene passed the `min_cells` detection filter.
952    #[error("scTransform: no gene of {n_genes} is detected in at least {min_cells} cells.")]
953    SctNoGenesPassFilter {
954        /// The filter that rejected everything
955        min_cells: usize,
956        /// Genes the filter was applied to
957        n_genes: usize,
958    },
959
960    /// A gene handed to the residual PCA is not covered by the model.
961    #[error("scTransform: gene {gene} is not covered by the fitted model.")]
962    SctGeneNotModelled {
963        /// Store index of the gene
964        gene: usize,
965    },
966
967    /// Pearson residuals and the shifted CLR transformation were both asked
968    /// for.
969    ///
970    /// CLR rewrites the stored log-normalised layer, which the residual path
971    /// never reads, so combining them would quietly do nothing.
972    #[error("PCA on Pearson residuals cannot be combined with the CLR transformation.")]
973    PcaResidualsWithClr,
974
975    /// Covariate columns handed in disagree in length.
976    #[error("scTransform covariate '{name}': expected {expected} values, got {found}.")]
977    SctCovariateLengthMismatch {
978        /// Name of the offending covariate
979        name: String,
980        /// Length the other columns had
981        expected: usize,
982        /// Length this one had
983        found: usize,
984    },
985
986    /// The covariates supplied do not match what the model was fitted with.
987    #[error("scTransform model was fitted with {model} covariate(s) but {supplied} were supplied.")]
988    SctCovariateCountMismatch {
989        /// Covariates the model carries
990        model: usize,
991        /// Covariates the caller supplied
992        supplied: usize,
993    },
994
995    // -- residual normalisation --
996    /// Group labels disagree in length with the selected cells.
997    #[error("Residual grouping: {n_labels} group label(s) for {n_cells} cell(s).")]
998    ResidualGroupLabelLengthMismatch {
999        /// Labels supplied
1000        n_labels: usize,
1001        /// Cells selected
1002        n_cells: usize,
1003    },
1004
1005    /// A group id has no cells in it.
1006    ///
1007    /// Labels must densely cover `0..n_groups`. A gap means the caller coded a
1008    /// factor without dropping its unused levels, and a model would be fitted
1009    /// against nothing.
1010    #[error("Residual grouping: group {group} of {n_groups} has no cells.")]
1011    ResidualEmptyGroup {
1012        /// The empty group id
1013        group: usize,
1014        /// Groups the labels imply
1015        n_groups: usize,
1016    },
1017
1018    /// No gene is modelled in every group.
1019    ///
1020    /// The shared feature axis is the intersection of the per-group modelled
1021    /// sets, so an empty intersection means the groups have no gene in common
1022    /// that passes `min_cells` everywhere. Usually one very shallow sample.
1023    #[error("Residual grouping: no gene is modelled in all {n_groups} groups.")]
1024    ResidualEmptyGeneIntersection {
1025        /// Groups that were intersected
1026        n_groups: usize,
1027    },
1028
1029    /// The count block handed to the analytic Pearson model sums to zero.
1030    ///
1031    /// `mu_cg = n_c * p_g` divides by the grand total, so a block of pure
1032    /// zeros has no model at all rather than a degenerate one.
1033    #[error("Analytic Pearson residuals: the selected counts sum to zero.")]
1034    AnalyticPearsonZeroTotal,
1035
1036    /// A non-positive overdispersion was supplied.
1037    #[error("Analytic Pearson residuals: theta must be positive, got {theta}.")]
1038    AnalyticPearsonInvalidTheta {
1039        /// The offending value
1040        theta: f64,
1041    },
1042
1043    /// An unusable residual clipping range was supplied.
1044    ///
1045    /// `f64::clamp` panics when the bounds are inverted or either is NaN, so
1046    /// this has to be caught before a rayon worker reaches it.
1047    #[error("Residual clipping range [{lo}, {hi}] must be finite with lo <= hi.")]
1048    ResidualInvalidClipRange {
1049        /// Lower bound supplied
1050        lo: f64,
1051        /// Upper bound supplied
1052        hi: f64,
1053    },
1054
1055    /// The selected cells contain a repeated index.
1056    ///
1057    /// The readers key their selection by an `IndexSet`, which deduplicates,
1058    /// while the residual row is sized by the raw selection. A duplicate would
1059    /// therefore shift every later cell into the wrong slot rather than
1060    /// erroring.
1061    #[error("Residual selection: {n_cells} cell(s) selected but only {n_unique} are distinct.")]
1062    ResidualDuplicateCells {
1063        /// Distinct cells in the selection
1064        n_unique: usize,
1065        /// Cells supplied
1066        n_cells: usize,
1067    },
1068
1069    /// A gene set that has to be ascending is not.
1070    ///
1071    /// Gene lookups are binary searches, so an unsorted axis silently returns
1072    /// the wrong position rather than failing.
1073    #[error("Residual model: '{name}' must be strictly ascending.")]
1074    ResidualGenesNotAscending {
1075        /// What was out of order
1076        name: &'static str,
1077    },
1078
1079    /// The group labels imply a different number of groups than there are
1080    /// models.
1081    #[error("Residual grouping: labels imply {implied} group(s) but {models} model(s) were given.")]
1082    ResidualGroupModelCountMismatch {
1083        /// Groups the labels imply
1084        implied: usize,
1085        /// Models supplied
1086        models: usize,
1087    },
1088
1089    /// A group label is outside the range the models cover.
1090    #[error("Residual grouping: label {label} is outside the {n_groups} group(s) available.")]
1091    ResidualGroupLabelOutOfRange {
1092        /// The offending label
1093        label: usize,
1094        /// Groups available
1095        n_groups: usize,
1096    },
1097
1098    /// A residual variance came out non-finite.
1099    ///
1100    /// Always an upstream problem, typically a model deserialised with a zero
1101    /// or missing scalar. Reported rather than floored, because a floored NaN
1102    /// silently ranks the gene last in feature selection.
1103    #[error("Residual variance for gene {gene} is not finite ({value}).")]
1104    ResidualNonFiniteVariance {
1105        /// Store index of the gene
1106        gene: usize,
1107        /// The offending value
1108        value: f64,
1109    },
1110
1111    /// The residual row's counts and indices disagree in length.
1112    #[error("Residual row: {n_counts} count(s) for {n_indices} cell index/indices.")]
1113    ResidualRowLengthMismatch {
1114        /// Counts supplied
1115        n_counts: usize,
1116        /// Indices supplied
1117        n_indices: usize,
1118    },
1119
1120    /// A residual row's cell index points outside the selected cells.
1121    #[error("Residual row: cell index {index} is outside the {n_cells} selected cell(s).")]
1122    ResidualCellIndexOutOfRange {
1123        /// The offending index
1124        index: usize,
1125        /// Cells selected
1126        n_cells: usize,
1127    },
1128
1129    /// Feature selection was asked for no genes, or handed no groups.
1130    #[error("Residual feature selection: {what}.")]
1131    ResidualEmptySelectionRequest {
1132        /// What was empty
1133        what: &'static str,
1134    },
1135
1136    /// The overdispersion search failed at every point it evaluated.
1137    ///
1138    /// Means the adjusted profile likelihood could not be computed anywhere in
1139    /// the bracket, typically a design that is rank-deficient for this gene's
1140    /// weights. The search would otherwise have minimised a constant and
1141    /// returned a fit built on a meaningless dispersion.
1142    #[error("scTransform: the dispersion search failed at every point: {reason}")]
1143    SctDispersionSearchFailed {
1144        /// The first underlying error, rendered
1145        reason: String,
1146    },
1147
1148    /// One group's fit failed.
1149    ///
1150    /// Wraps the underlying error so that a failure in group 7 of 8 is not
1151    /// indistinguishable from one in group 0. The usual cause is a single
1152    /// shallow sample where no gene clears `min_cells`.
1153    #[error("Residual grouping: group {group} failed to fit: {reason}")]
1154    ResidualGroupFitFailed {
1155        /// The group that failed
1156        group: usize,
1157        /// The underlying error, rendered
1158        reason: String,
1159    },
1160
1161    /// The covariates supplied are not the ones the model was fitted with.
1162    ///
1163    /// Only the count is cheap to check, and the count matching is exactly the
1164    /// case where a reordered data frame goes unnoticed, so the names are
1165    /// compared too.
1166    #[error(
1167        "scTransform covariate {position}: model was fitted with '{model}' but '{supplied}' was supplied."
1168    )]
1169    SctCovariateNameMismatch {
1170        /// Position in the design, excluding the intercept
1171        position: usize,
1172        /// Name the model carries
1173        model: String,
1174        /// Name the caller supplied
1175        supplied: String,
1176    },
1177
1178    /// A cell index handed to scTransform is outside the selection.
1179    #[error("scTransform: cell index {index} is outside the {n_cells} selected cell(s).")]
1180    SctCellIndexOutOfRange {
1181        /// The offending index
1182        index: usize,
1183        /// Cells selected
1184        n_cells: usize,
1185    },
1186
1187    /// A fitted model carries no design columns at all.
1188    #[error("scTransform model has n_coef = 0; at least the intercept is required.")]
1189    SctModelWithoutCoefficients,
1190
1191    /// Variance normalisation was asked for on the residual PCA path.
1192    ///
1193    /// Residuals carry the biological signal as variance, which is the point of
1194    /// the transformation. Dividing it back out per gene flattens exactly the
1195    /// ranking the transformation produces, so this is refused rather than
1196    /// silently honoured.
1197    #[error(
1198        "PCA on Pearson residuals cannot normalise the variance; residuals already carry it as signal."
1199    )]
1200    PcaResidualsWithVarianceNormalisation,
1201
1202    // -- cellsweep --
1203    /// The caller asked for a supplied empty droplet mask but did not give one.
1204    #[error("CellSweep: The empty droplet mask was not supplied but was requested.")]
1205    CellSweepEmptyMaskMissing,
1206
1207    /// The expected cell count cannot be turned into a library size cutoff.
1208    #[error("CellSweep: Cannot take {expected} expected cells from {barcodes} barcodes.")]
1209    CellSweepBadExpectedCells {
1210        /// Number of expected cells requested
1211        expected: usize,
1212        /// Number of barcodes available
1213        barcodes: usize,
1214    },
1215
1216    /// The knee detector could not find a cutoff.
1217    #[error("CellSweep: Cannot locate a knee from {barcodes} barcodes above the minimum count.")]
1218    CellSweepKneeNotFound {
1219        /// Number of barcodes that survived the minimum count filter
1220        barcodes: usize,
1221    },
1222
1223    /// A sample has too few empty droplets to estimate an ambient profile.
1224    #[error(
1225        "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."
1226    )]
1227    CellSweepTooFewEmptyDroplets {
1228        /// Identifier of the offending sample
1229        sample_id: String,
1230        /// Empty droplets found
1231        found: usize,
1232        /// Empty droplets required
1233        required: usize,
1234    },
1235
1236    /// A sample carries no annotated barcodes.
1237    #[error("CellSweep: Sample '{sample_id}' has no annotated barcodes to fit.")]
1238    CellSweepNoRealCells {
1239        /// Identifier of the offending sample
1240        sample_id: String,
1241    },
1242
1243    /// A cell-type code points outside the profile matrix.
1244    #[error(
1245        "CellSweep: Sample '{sample_id}' has cell-type code {code} but only {n_celltypes} cell types."
1246    )]
1247    CellSweepCelltypeOutOfRange {
1248        /// Identifier of the offending sample
1249        sample_id: String,
1250        /// The offending cell-type code
1251        code: usize,
1252        /// Number of cell types declared
1253        n_celltypes: usize,
1254    },
1255
1256    /// The label vector does not line up with the barcode vector.
1257    #[error(
1258        "CellSweep: Sample '{sample_id}' has {n_cells} real barcodes but {n_labels} cell-type labels."
1259    )]
1260    CellSweepLabelLengthMismatch {
1261        /// Identifier of the offending sample
1262        sample_id: String,
1263        /// Number of real barcodes
1264        n_cells: usize,
1265        /// Number of labels given
1266        n_labels: usize,
1267    },
1268
1269    /// `freeze_empties = false` is not supported.
1270    #[error(
1271        "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."
1272    )]
1273    CellSweepFreezeEmptiesUnsupported,
1274
1275    /// The EM produced a non-finite log-likelihood.
1276    #[error("CellSweep: Sample '{sample_id}' diverged at iteration {iteration}.")]
1277    CellSweepDiverged {
1278        /// Identifier of the offending sample
1279        sample_id: String,
1280        /// Iteration at which the log-likelihood went non-finite
1281        iteration: usize,
1282    },
1283
1284    // -- wnn --
1285    /// Error if the modalities do not have the same number of cells
1286    #[error("WNN: Both modalities need to have the same cell numbers.")]
1287    WNNModalitySampleMismatch,
1288
1289    /// Error if k_nn > knn_range
1290    #[error("WNN: k_nn must be <= knn_range")]
1291    WNNKnnLargerThanKnnRange,
1292
1293    /// Error if sigma_idx >= knn_range
1294    #[error("WNN: sigma_idx must be < knn_range")]
1295    WNNSigmaIdxOutOfRange,
1296
1297    /// Error if s_nn > knn_range
1298    #[error("WNN: s_nn must be <= knn_range")]
1299    WNNSnnLargerThanKnnRange,
1300
1301    /// Error if a modality's kNN row count does not match n_cells
1302    #[error("WNN: modality {modality} kNN row count {found} does not match n_cells {expected}")]
1303    WNNKnnRowCountMismatch {
1304        /// 0 or 1
1305        modality: usize,
1306        /// Expected count
1307        expected: usize,
1308        /// Found count
1309        found: usize,
1310    },
1311
1312    /// Error if a modality's kNN distance row count does not match n_cells
1313    #[error(
1314        "WNN: modality {modality} kNN distance row count {found} does not match n_cells {expected}"
1315    )]
1316    WNNKnnDistRowCountMismatch {
1317        /// 0 or 1
1318        modality: usize,
1319        /// Expected count
1320        expected: usize,
1321        /// Found count
1322        found: usize,
1323    },
1324
1325    /// Error if a modality has fewer than knn_range neighbours per cell
1326    #[error(
1327        "WNN: modality {modality} needs at least {expected} neighbours per cell, found {found}"
1328    )]
1329    WNNInsufficientNeighbours {
1330        /// 0 or 1
1331        modality: usize,
1332        /// knn_range
1333        expected: usize,
1334        /// Actual neighbour count in row 0
1335        found: usize,
1336    },
1337}
1338
1339/// Sparse structure errors surface through the core variant, as before the
1340/// sparse structures moved to `bixverse-sparse`.
1341impl From<bixverse_core::errors::SparseErrors> for ScErrors {
1342    fn from(err: bixverse_core::errors::SparseErrors) -> Self {
1343        ScErrors::Core(err.into())
1344    }
1345}