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}