bixverse_sc_data/in_memory_io.rs
1//! An in-memory [`SingleCellReading`] store over [`CompressedSparseData2`].
2//!
3//! Everything in `sc_analysis` is written against the streaming reader, which
4//! assumes the counts live in the bixverse binary format on disk. Metacells do
5//! not: they compress a whole experiment down to something that fits in memory
6//! comfortably, and the R side hands them over as a sparse matrix. This adapter
7//! presents such a matrix through the same trait so those methods can be reused
8//! rather than reimplemented.
9//!
10//! Gene-major by default. Nothing that consumes it needs cell chunks, and
11//! building a CSR twin purely to serve them would double the memory for no
12//! gain, so [`SingleCellReading::read_cells_parallel`] refuses and the one
13//! cell-side quantity that is actually needed, the library sizes, is summed
14//! once up front.
15//!
16//! [`InMemorySparseReader::new_cell_major`] is the exception, for the callers
17//! that genuinely need cell chunks: the analytic Pearson fit sums each cell
18//! over its group's retained genes, which no gene-major sweep can answer. It
19//! owns a real CSR copy of the matrix, so it costs the doubled memory the
20//! default avoids. Worth it for metacells, which is what asks for it; do not
21//! reach for it on a raw-cell matrix.
22
23use indexmap::IndexSet;
24use num_traits::FromPrimitive;
25use rayon::prelude::*;
26use rustc_hash::FxHashMap;
27
28use crate::data_io::{
29 CscGeneChunk, CsrCellChunk, DataLayerReturn, ParallelSparseReader, RawCounts, RunSource,
30 SingleCellReading, SparseDataHeader, cell_row_map, extend_raw_checked, read_fused,
31};
32use crate::prelude::*;
33use crate::sc_traits::F16;
34use bixverse_sparse::sparse::transpose_sparse;
35
36//////////////////////////
37// InMemorySparseReader //
38//////////////////////////
39
40/// Gene-major in-memory store behind the [`SingleCellReading`] interface.
41///
42/// Reads are slices of the underlying buffers copied into the chunk types the
43/// trait hands back, so a full gene sweep costs one copy of the matrix rather
44/// than a decompression per chunk.
45pub struct InMemorySparseReader<'a> {
46 /// The counts, CSC with shape (cells, genes). `data` holds raw counts and
47 /// `data_2` the normalised layer.
48 ///
49 /// In cell-major mode this still points at the caller's CSC matrix and the
50 /// reads come off `transposed` instead. Keeping it lets both modes share
51 /// the shape and the library-size sweep.
52 matrix: &'a CompressedSparseData2<u32, f32>,
53 /// The CSR twin, present only in cell-major mode.
54 ///
55 /// Owned, because it is a real scatter of `matrix` rather than a view: a
56 /// CSC relabelled as CSR describes the transpose, which is the wrong axis
57 /// here.
58 transposed: Option<CompressedSparseData2<u32, f32>>,
59 /// Total raw counts per cell, summed once in [`InMemorySparseReader::new`].
60 library_sizes: Vec<usize>,
61 /// Header describing the store. The file-layout fields carry no meaning
62 /// here: `chunk_offsets` is empty and `index_map` is the identity.
63 header: SparseDataHeader,
64 /// Library size the normalised layer was scaled to, when known.
65 target_size: Option<f32>,
66}
67
68impl<'a> InMemorySparseReader<'a> {
69 /// Wrap a sparse matrix as a gene-major store.
70 ///
71 /// ### Params
72 ///
73 /// * `matrix` - The counts, CSC with shape (cells, genes). Raw counts in
74 /// `data`, normalised counts in `data_2`.
75 /// * `target_size` - Library size `data_2` was normalised against, if the
76 /// caller knows it.
77 ///
78 /// ### Returns
79 ///
80 /// The reader, or [`SparseErrors::SparseMatrixMustBeCsc`] for a CSR input
81 /// and [`SparseErrors::Data2NotAvailable`] when the normalised layer is
82 /// missing.
83 pub fn new(
84 matrix: &'a CompressedSparseData2<u32, f32>,
85 target_size: Option<f32>,
86 ) -> Result<Self, ScDataErrors> {
87 if !matrix.cs_type.is_csc() {
88 return Err(SparseErrors::SparseMatrixMustBeCsc.into());
89 }
90 if matrix.data_2.is_none() {
91 return Err(SparseErrors::Data2NotAvailable.into());
92 }
93
94 let (n_cells, n_genes) = matrix.shape;
95 let library_sizes = cell_library_sizes(matrix, n_cells);
96
97 let header = SparseDataHeader {
98 total_cells: n_cells,
99 total_genes: n_genes,
100 cell_based: false,
101 no_chunks: n_genes,
102 chunk_offsets: Vec::new(),
103 index_map: (0..n_genes)
104 .map(|gene| (gene, gene))
105 .collect::<FxHashMap<_, _>>(),
106 };
107
108 Ok(Self {
109 matrix,
110 transposed: None,
111 library_sizes,
112 header,
113 target_size,
114 })
115 }
116
117 /// Wrap the same matrix as a cell-major store.
118 ///
119 /// Builds and owns a CSR twin, so peak memory is roughly twice the matrix
120 /// for as long as the reader lives. Use it only where cell chunks are
121 /// genuinely needed, such as the per-cell totals the analytic Pearson fit
122 /// sums over each group's retained genes.
123 ///
124 /// Note this is not [`CompressedSparseData2::transpose_and_convert`], which
125 /// relabels a CSC as a CSR and swaps the shape, describing the transpose.
126 /// What is wanted here is the same matrix in the other layout, which is a
127 /// real scatter.
128 ///
129 /// ### Params
130 ///
131 /// * `matrix` - The counts, CSC with shape (cells, genes). Raw counts in
132 /// `data`, normalised counts in `data_2`.
133 /// * `target_size` - Library size `data_2` was normalised against, if the
134 /// caller knows it.
135 ///
136 /// ### Returns
137 ///
138 /// The reader, or [`SparseErrors::SparseMatrixMustBeCsc`] for a CSR input
139 /// and [`SparseErrors::Data2NotAvailable`] when the normalised layer is
140 /// missing.
141 pub fn new_cell_major(
142 matrix: &'a CompressedSparseData2<u32, f32>,
143 target_size: Option<f32>,
144 ) -> Result<Self, ScDataErrors> {
145 let mut reader = Self::new(matrix, target_size)?;
146 let n_cells = matrix.shape.0;
147
148 // `transpose_sparse` keeps the shape and changes the layout, so this is
149 // CSR over (cells, genes): one run per cell, gene indices ascending.
150 reader.transposed = Some(transpose_sparse(matrix));
151 reader.header.cell_based = true;
152 reader.header.no_chunks = n_cells;
153 reader.header.index_map = (0..n_cells).map(|cell| (cell, cell)).collect();
154
155 Ok(reader)
156 }
157
158 /// Build the chunk for one cell.
159 ///
160 /// ### Params
161 ///
162 /// * `cell` - Cell index
163 ///
164 /// ### Returns
165 ///
166 /// The [CsrCellChunk], or [`ScDataErrors::ReaderModeMismatch`] in
167 /// gene-major mode and [`ScDataErrors::ChunkIndexNotFound`] when the
168 /// index is past the cell axis.
169 fn cell_chunk(&self, cell: usize) -> Result<CsrCellChunk, ScDataErrors> {
170 let transposed = self
171 .transposed
172 .as_ref()
173 .ok_or(ScDataErrors::ReaderModeMismatch {
174 actual: "gene-based",
175 requested: "cell-based",
176 })?;
177
178 if cell >= self.matrix.shape.0 {
179 return Err(ScDataErrors::ChunkIndexNotFound(cell));
180 }
181
182 let lo = transposed.indptr[cell] as usize;
183 let hi = transposed.indptr[cell + 1] as usize;
184
185 let data_raw = RawCounts::from_u32_auto(&transposed.data[lo..hi]);
186 let data_norm: Vec<F16> = transposed.data_2.as_ref().expect("checked in `new`")[lo..hi]
187 .iter()
188 .map(|&v| F16::from_f32(v))
189 .collect();
190
191 Ok(CsrCellChunk {
192 data_raw,
193 data_norm,
194 library_size: self.library_sizes[cell],
195 indices: transposed.indices[lo..hi].to_vec(),
196 original_index: cell,
197 to_keep: true,
198 })
199 }
200
201 /// Build the chunk for one gene.
202 ///
203 /// ### Params
204 ///
205 /// * `gene` - Gene index
206 ///
207 /// ### Returns
208 ///
209 /// The [CscGeneChunk], or [`ScDataErrors::ChunkIndexNotFound`] when the
210 /// index is past the gene axis.
211 fn gene_chunk(&self, gene: usize) -> Result<CscGeneChunk, ScDataErrors> {
212 if gene >= self.matrix.shape.1 {
213 return Err(ScDataErrors::ChunkIndexNotFound(gene));
214 }
215
216 let lo = self.matrix.indptr[gene] as usize;
217 let hi = self.matrix.indptr[gene + 1] as usize;
218
219 let data_raw = RawCounts::from_u32_auto(&self.matrix.data[lo..hi]);
220 let data_norm: Vec<F16> = self.matrix.data_2.as_ref().expect("checked in `new`")[lo..hi]
221 .iter()
222 .map(|&v| F16::from_f32(v))
223 .collect();
224
225 let avg_exp = F16::from_f32(data_norm.iter().map(|v| v.to_f32()).sum::<f32>());
226
227 Ok(CscGeneChunk {
228 data_raw,
229 data_norm,
230 avg_exp,
231 nnz: hi - lo,
232 indices: self.matrix.indices[lo..hi].to_vec(),
233 original_index: gene,
234 to_keep: true,
235 })
236 }
237}
238
239/// One run of an in-memory store, borrowed straight from the matrix.
240struct SliceRun<'a> {
241 /// Stored indices of the run.
242 indices: &'a [u32],
243 /// Raw counts of the run.
244 raw: &'a [u32],
245 /// Normalised values of the run.
246 norm: &'a [f32],
247}
248
249impl RunSource for SliceRun<'_> {
250 #[inline]
251 fn nnz(&self) -> usize {
252 self.indices.len()
253 }
254
255 #[inline(always)]
256 fn index_at(&self, i: usize) -> u32 {
257 self.indices[i]
258 }
259
260 #[inline(always)]
261 fn raw_at(&self, i: usize) -> u32 {
262 self.raw[i]
263 }
264
265 #[inline(always)]
266 fn norm_at(&self, i: usize) -> f32 {
267 self.norm[i]
268 }
269
270 fn extend_indices(&self, out: &mut Vec<u32>) {
271 out.extend_from_slice(self.indices);
272 }
273
274 fn extend_raw<T: FromPrimitive>(&self, out: &mut Vec<T>) -> Result<(), ScDataErrors> {
275 extend_raw_checked(out, self.raw.iter().copied())
276 }
277
278 fn extend_norm(&self, out: &mut Vec<f32>) {
279 out.extend_from_slice(self.norm);
280 }
281}
282
283/// Copy a batch of runs out of an in-memory store into one sparse matrix.
284///
285/// The in-memory counterpart of the decompress-and-append fill in
286/// [`ParallelSparseReader`]: each run is a slice of `store`, appended through
287/// the same [`read_fused`] engine and filter.
288///
289/// ### Params
290///
291/// * `store` - The matrix whose major axis the runs index
292/// * `indices` - Runs to read, in output order
293/// * `row_map` - Optional dense `index -> output row` map
294/// * `data_layer` - Which layer(s) to materialise
295/// * `cs_type` - Layout of the output
296/// * `shape` - `(cells, genes)` of the output
297///
298/// ### Returns
299///
300/// The batch, or [`ScDataErrors::ChunkIndexNotFound`] for a run past the
301/// major axis.
302fn read_runs<T>(
303 store: &CompressedSparseData2<u32, f32>,
304 indices: &[usize],
305 row_map: Option<&[u32]>,
306 data_layer: &DataLayerReturn,
307 cs_type: CompressedSparseFormat,
308 shape: (usize, usize),
309) -> Result<CompressedSparseData2<T, f32>, ScDataErrors>
310where
311 T: BixverseNumeric + FromPrimitive,
312{
313 let norm = store.data_2.as_ref().expect("checked in `new`");
314 let n_runs = store.indptr.len() - 1;
315
316 read_fused(indices, data_layer, cs_type, shape, |run, _, fragment| {
317 if run >= n_runs {
318 return Err(ScDataErrors::ChunkIndexNotFound(run));
319 }
320 let lo = store.indptr[run] as usize;
321 let hi = store.indptr[run + 1] as usize;
322 let run = SliceRun {
323 indices: &store.indices[lo..hi],
324 raw: &store.data[lo..hi],
325 norm: &norm[lo..hi],
326 };
327 fragment.push_run(&run, row_map)
328 })
329}
330
331/// Total raw counts per cell of a gene-major matrix.
332///
333/// A CSC matrix has no per-cell run to sum, so this is a scatter-add over every
334/// stored value. Each worker takes a contiguous block of genes and its own
335/// `n_cells` accumulator, which for metacells is a few hundred kilobytes.
336///
337/// ### Params
338///
339/// * `matrix` - The counts, CSC with shape (cells, genes)
340/// * `n_cells` - Number of cells
341///
342/// ### Returns
343///
344/// One library size per cell.
345fn cell_library_sizes(matrix: &CompressedSparseData2<u32, f32>, n_cells: usize) -> Vec<usize> {
346 let n_genes = matrix.shape.1;
347
348 let totals = thread_chunks(n_genes)
349 .par_iter()
350 .map(|&(gene_start, gene_end)| {
351 let mut acc = vec![0u64; n_cells];
352
353 let lo = matrix.indptr[gene_start] as usize;
354 let hi = matrix.indptr[gene_end] as usize;
355
356 for (&cell, &count) in matrix.indices[lo..hi]
357 .iter()
358 .zip(matrix.data[lo..hi].iter())
359 {
360 acc[cell as usize] += count as u64;
361 }
362
363 acc
364 })
365 .reduce(
366 || vec![0u64; n_cells],
367 |mut a, b| {
368 for (total, add) in a.iter_mut().zip(b.iter()) {
369 *total += add;
370 }
371 a
372 },
373 );
374
375 totals.into_iter().map(|x| x as usize).collect()
376}
377
378/////////////////
379// Cell subset //
380/////////////////
381
382/// Largest share of the store's cells for which reading the subset from the
383/// cell-major store beats the gene-major sweep.
384///
385/// Measured on a 985k-cell, 36.5k-gene store (10 threads, warm page cache):
386/// VST HVG breaks even near 12 % of cells, the scTransform fit near 20 %. 10 %
387/// sits inside both. A cold cache only pushes the crossover out, since the
388/// gene-major path reads the whole file whatever the subset.
389pub const CELL_SUBSET_MAX_FRACTION: f64 = 0.10;
390
391/// Largest subset ever materialised in memory, whatever the fraction.
392///
393/// At ~1.2k non-zeros per cell, 100k cells is ~1.4 GB of CSR, with the same
394/// again briefly during the transpose to CSC.
395pub const CELL_SUBSET_MAX_CELLS: usize = 100_000;
396
397/// Should a subset go through [`read_cell_subset_csc`] rather than the
398/// gene-major store?
399///
400/// ### Params
401///
402/// * `n_selected` - Cells in the subset
403/// * `n_total` - Cells in the store
404///
405/// ### Returns
406///
407/// `true` when the subset is within both [`CELL_SUBSET_MAX_FRACTION`] of the
408/// store and [`CELL_SUBSET_MAX_CELLS`].
409pub fn prefer_cell_subset(n_selected: usize, n_total: usize) -> bool {
410 n_selected <= CELL_SUBSET_MAX_CELLS
411 && (n_selected as f64) <= CELL_SUBSET_MAX_FRACTION * n_total as f64
412}
413
414/// Read a cell subset from the cell-major store into a gene-major matrix.
415///
416/// The gene-major drivers (HVG, scTransform) decompress every gene chunk in
417/// full and drop the unwanted cells afterwards, so their cost follows the
418/// store's non-zeros, not the subset's. For a small subset it is cheaper to
419/// read just its cells and run the same drivers over an
420/// [`InMemorySparseReader`] built on the result.
421///
422/// Every gene is kept, so gene indices stay store indices. Cells are
423/// renumbered `0..cell_indices.len()` in the given order. The normalised layer
424/// is F16 on disk in both stores, so the F16 -> f32 -> F16 round trip through
425/// the reader is exact.
426///
427/// ### Params
428///
429/// * `cell_reader` - Reader over the cell-major store
430/// * `cell_indices` - Cells to read, in the order they should appear
431///
432/// ### Returns
433///
434/// The subset as CSC with shape `(cell_indices.len(), total_genes)`, raw
435/// counts in `data` and normalised counts in `data_2`.
436pub fn read_cell_subset_csc(
437 cell_reader: &ParallelSparseReader,
438 cell_indices: &[usize],
439) -> Result<CompressedSparseData2<u32, f32>, ScDataErrors> {
440 let batch = cell_reader.read_cells_csr(cell_indices, &DataLayerReturn::BothLayers)?;
441
442 // Raw counts come back as f32, exact for anything below 2^24.
443 let data: Vec<u32> = batch
444 .data_raw
445 .expect("requested both layers")
446 .par_iter()
447 .map(|&v| v as u32)
448 .collect();
449
450 let csr = CompressedSparseData2::from_parts(
451 data,
452 batch.indices,
453 batch.indptr,
454 batch.data_norm,
455 CompressedSparseFormat::Csr,
456 batch.shape,
457 );
458
459 Ok(transpose_sparse(&csr))
460}
461
462impl SingleCellReading for InMemorySparseReader<'_> {
463 /// Read cells by index in a multi-threaded manner
464 ///
465 /// Only available on a reader built with
466 /// [`InMemorySparseReader::new_cell_major`]; the default gene-major reader
467 /// has no CSR twin to read from.
468 ///
469 /// ### Params
470 ///
471 /// * `indices` - Index positions of the cells to retrieve
472 ///
473 /// ### Returns
474 ///
475 /// The [CsrCellChunk]s in the order given by `indices`, or
476 /// [`ScDataErrors::ReaderModeMismatch`] in gene-major mode.
477 fn read_cells_parallel(&self, indices: &[usize]) -> Result<Vec<CsrCellChunk>, ScDataErrors> {
478 indices
479 .par_iter()
480 .map(|&cell| self.cell_chunk(cell))
481 .collect()
482 }
483
484 /// Read a batch of cells straight into CSR
485 ///
486 /// Slices the CSR twin directly, so no chunk is built and the normalised
487 /// layer stays `f32` rather than passing through the chunks' F16.
488 ///
489 /// ### Params
490 ///
491 /// * `indices` - Cell indices, in output row order
492 /// * `data_layer` - Which layer(s) to materialise
493 ///
494 /// ### Returns
495 ///
496 /// The batch as CSR, or [`ScDataErrors::ReaderModeMismatch`] in
497 /// gene-major mode.
498 fn read_cells_sparse<T>(
499 &self,
500 indices: &[usize],
501 data_layer: &DataLayerReturn,
502 ) -> Result<CompressedSparseData2<T, f32>, ScDataErrors>
503 where
504 T: BixverseNumeric + FromPrimitive,
505 {
506 let transposed = self
507 .transposed
508 .as_ref()
509 .ok_or(ScDataErrors::ReaderModeMismatch {
510 actual: "gene-based",
511 requested: "cell-based",
512 })?;
513
514 read_runs(
515 transposed,
516 indices,
517 None,
518 data_layer,
519 CompressedSparseFormat::Csr,
520 (indices.len(), self.matrix.shape.1),
521 )
522 }
523
524 /// Read a batch of genes straight into CSC, optionally filtering cells
525 ///
526 /// Slices the matrix directly, so no chunk is built and the normalised
527 /// layer stays `f32` rather than passing through the chunks' F16.
528 ///
529 /// ### Params
530 ///
531 /// * `indices` - Gene indices, in output column order
532 /// * `cells_to_keep` - Cells to keep, in output row order, or `None`
533 /// * `data_layer` - Which layer(s) to materialise
534 ///
535 /// ### Returns
536 ///
537 /// The batch as CSC, or [`ScDataErrors::ReaderModeMismatch`] in
538 /// cell-major mode.
539 fn read_genes_sparse<T>(
540 &self,
541 indices: &[usize],
542 cells_to_keep: Option<&IndexSet<u32>>,
543 data_layer: &DataLayerReturn,
544 ) -> Result<CompressedSparseData2<T, f32>, ScDataErrors>
545 where
546 T: BixverseNumeric + FromPrimitive,
547 {
548 if self.transposed.is_some() {
549 return Err(ScDataErrors::ReaderModeMismatch {
550 actual: "cell-based",
551 requested: "gene-based",
552 });
553 }
554
555 let (row_map, n_rows) = cell_row_map(cells_to_keep, self.matrix.shape.0);
556
557 read_runs(
558 self.matrix,
559 indices,
560 row_map.as_deref(),
561 data_layer,
562 CompressedSparseFormat::Csc,
563 (n_rows, indices.len()),
564 )
565 }
566
567 /// Read genes by index in a multi-threaded manner
568 ///
569 /// ### Params
570 ///
571 /// * `indices` - Index positions of the genes to retrieve
572 ///
573 /// ### Returns
574 ///
575 /// The [CscGeneChunk]s in the order given by `indices`, or
576 /// [`ScDataErrors::ReaderModeMismatch`] in cell-major mode.
577 fn read_gene_parallel(&self, indices: &[usize]) -> Result<Vec<CscGeneChunk>, ScDataErrors> {
578 if self.transposed.is_some() {
579 return Err(ScDataErrors::ReaderModeMismatch {
580 actual: "cell-based",
581 requested: "gene-based",
582 });
583 }
584
585 indices
586 .par_iter()
587 .map(|&gene| self.gene_chunk(gene))
588 .collect()
589 }
590
591 /// Return the header of the underlying store
592 ///
593 /// ### Returns
594 ///
595 /// The [SparseDataHeader].
596 fn get_header(&self) -> SparseDataHeader {
597 self.header.clone()
598 }
599
600 /// Is the store laid out for fast cell retrieval?
601 ///
602 /// ### Returns
603 ///
604 /// `true` when built with [`InMemorySparseReader::new_cell_major`].
605 fn is_cell_based(&self) -> bool {
606 self.transposed.is_some()
607 }
608
609 /// Library size the normalised layer was scaled to.
610 ///
611 /// ### Returns
612 ///
613 /// Whatever the caller passed to [`InMemorySparseReader::new`].
614 fn target_size(&self) -> Option<f32> {
615 self.target_size
616 }
617
618 /// Total raw counts per cell
619 ///
620 /// Served from the sums taken in [`InMemorySparseReader::new`], which is
621 /// why a gene-major store can answer a cell-side question at all.
622 ///
623 /// ### Params
624 ///
625 /// * `indices` - Cell indices
626 ///
627 /// ### Returns
628 ///
629 /// The library sizes in the order given by `indices`, or
630 /// [`ScDataErrors::ChunkIndexNotFound`] for an index past the cell axis.
631 fn read_cell_library_sizes(&self, indices: &[usize]) -> Result<Vec<usize>, ScDataErrors> {
632 indices
633 .iter()
634 .map(|&cell| {
635 self.library_sizes
636 .get(cell)
637 .copied()
638 .ok_or(ScDataErrors::ChunkIndexNotFound(cell))
639 })
640 .collect()
641 }
642}
643
644///////////
645// Tests //
646///////////
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651
652 /// Four cells, three genes. Gene 1 is expressed everywhere, gene 2 in a
653 /// single cell.
654 fn toy_csc() -> CompressedSparseData2<u32, f32> {
655 let data: Vec<u32> = vec![
656 3, 5, // gene 0, cells 0 and 2
657 1, 2, 4, 1, // gene 1, all four cells
658 7, // gene 2, cell 3
659 ];
660 let data_2: Vec<f32> = data.iter().map(|&v| (v as f32).ln_1p()).collect();
661 let indices: Vec<u32> = vec![0, 2, 0, 1, 2, 3, 3];
662 let indptr: Vec<u32> = vec![0, 2, 6, 7];
663
664 CompressedSparseData2::new_csc(&data, &indices, &indptr, Some(&data_2), (4, 3))
665 }
666
667 /// Library sizes sum down cells even though the storage is gene-major.
668 #[test]
669 fn test_library_sizes_are_the_cell_totals() {
670 let matrix = toy_csc();
671 let reader = InMemorySparseReader::new(&matrix, None).unwrap();
672
673 let sizes = reader.read_cell_library_sizes(&[0, 1, 2, 3]).unwrap();
674 assert_eq!(sizes, vec![4, 2, 9, 8]);
675 }
676
677 /// Gene chunks come back in request order with the stored column untouched.
678 #[test]
679 fn test_gene_chunks_carry_the_column_verbatim() {
680 let matrix = toy_csc();
681 let reader = InMemorySparseReader::new(&matrix, None).unwrap();
682
683 let chunks = reader.read_gene_parallel(&[2, 0]).unwrap();
684
685 // order follows the request, not the storage
686 assert_eq!(chunks[0].original_index, 2);
687 assert_eq!(chunks[0].indices, vec![3]);
688 assert_eq!(chunks[0].data_raw.iter().collect::<Vec<u32>>(), vec![7]);
689 assert_eq!(chunks[0].nnz, 1);
690
691 assert_eq!(chunks[1].original_index, 0);
692 assert_eq!(chunks[1].indices, vec![0, 2]);
693 assert_eq!(chunks[1].data_raw.iter().collect::<Vec<u32>>(), vec![3, 5]);
694 }
695
696 /// The synthesised header reports the matrix shape and the reader stays gene-based only.
697 #[test]
698 fn test_header_and_mode() {
699 let matrix = toy_csc();
700 let reader = InMemorySparseReader::new(&matrix, Some(1e4)).unwrap();
701
702 let header = reader.get_header();
703 assert_eq!(header.total_cells, 4);
704 assert_eq!(header.total_genes, 3);
705 assert!(!reader.is_cell_based());
706 assert!(reader.is_gene_based());
707 assert_eq!(reader.target_size(), Some(1e4));
708 assert!(reader.read_cells_parallel(&[0]).is_err());
709 }
710
711 /// The reader is CSC-only, so a CSR matrix must be refused at construction.
712 #[test]
713 fn test_csr_input_is_rejected() {
714 let csc = toy_csc();
715 let csr = csc.transform();
716
717 assert!(InMemorySparseReader::new(&csr, None).is_err());
718 }
719
720 /// Out-of-range gene and cell indices error rather than reading past the buffers.
721 #[test]
722 fn test_out_of_range_indices_error() {
723 let matrix = toy_csc();
724 let reader = InMemorySparseReader::new(&matrix, None).unwrap();
725
726 assert!(reader.read_gene_parallel(&[3]).is_err());
727 assert!(reader.read_cell_library_sizes(&[4]).is_err());
728 }
729
730 /// Cell chunks carry the row the CSC describes, in request order.
731 #[test]
732 fn test_cell_chunks_carry_the_row_verbatim() {
733 let matrix = toy_csc();
734 let reader = InMemorySparseReader::new_cell_major(&matrix, None).unwrap();
735
736 let chunks = reader.read_cells_parallel(&[3, 0]).unwrap();
737
738 assert_eq!(chunks[0].original_index, 3);
739 assert_eq!(chunks[0].indices, vec![1, 2]);
740 assert_eq!(chunks[0].data_raw.iter().collect::<Vec<u32>>(), vec![1, 7]);
741 assert_eq!(chunks[0].library_size, 8);
742
743 assert_eq!(chunks[1].original_index, 0);
744 assert_eq!(chunks[1].indices, vec![0, 1]);
745 assert_eq!(chunks[1].data_raw.iter().collect::<Vec<u32>>(), vec![3, 1]);
746 assert_eq!(chunks[1].library_size, 4);
747 }
748
749 /// The two modes are exclusive: each serves its own axis and refuses the
750 /// other, so a caller cannot silently get the wrong orientation.
751 #[test]
752 fn test_cell_major_mode_refuses_gene_reads() {
753 let matrix = toy_csc();
754 let reader = InMemorySparseReader::new_cell_major(&matrix, Some(1e4)).unwrap();
755
756 assert!(reader.is_cell_based());
757 assert!(!reader.is_gene_based());
758 assert!(reader.get_header().cell_based);
759 assert_eq!(reader.get_header().total_cells, 4);
760 assert_eq!(reader.target_size(), Some(1e4));
761 assert!(reader.read_gene_parallel(&[0]).is_err());
762 }
763
764 /// Every stored value survives the transpose, so the two orientations hold
765 /// the same matrix.
766 #[test]
767 fn test_cell_major_preserves_every_entry() {
768 let matrix = toy_csc();
769 let gene_major = InMemorySparseReader::new(&matrix, None).unwrap();
770 let cell_major = InMemorySparseReader::new_cell_major(&matrix, None).unwrap();
771
772 let mut from_genes: Vec<(u32, usize, u32)> = Vec::new();
773 for chunk in gene_major.read_gene_parallel(&[0, 1, 2]).unwrap() {
774 for (&cell, count) in chunk.indices.iter().zip(chunk.data_raw.iter()) {
775 from_genes.push((cell, chunk.original_index, count));
776 }
777 }
778
779 let mut from_cells: Vec<(u32, usize, u32)> = Vec::new();
780 for chunk in cell_major.read_cells_parallel(&[0, 1, 2, 3]).unwrap() {
781 for (&gene, count) in chunk.indices.iter().zip(chunk.data_raw.iter()) {
782 from_cells.push((chunk.original_index as u32, gene as usize, count));
783 }
784 }
785
786 from_genes.sort_unstable();
787 from_cells.sort_unstable();
788 assert_eq!(from_genes, from_cells);
789 }
790
791 /// Out-of-range cell indices error rather than reading past the buffers.
792 #[test]
793 fn test_cell_major_out_of_range_errors() {
794 let matrix = toy_csc();
795 let reader = InMemorySparseReader::new_cell_major(&matrix, None).unwrap();
796
797 assert!(reader.read_cells_parallel(&[4]).is_err());
798 }
799
800 /// Reading every gene in order hands back the matrix itself, norm layer
801 /// still `f32`.
802 #[test]
803 fn test_read_genes_sparse_is_the_matrix() {
804 let matrix = toy_csc();
805 let reader = InMemorySparseReader::new(&matrix, None).unwrap();
806
807 let csc = reader
808 .read_genes_sparse::<u32>(&[0, 1, 2], None, &DataLayerReturn::BothLayers)
809 .unwrap();
810
811 assert_eq!(csc.shape, matrix.shape);
812 assert_eq!(csc.indptr, matrix.indptr);
813 assert_eq!(csc.indices, matrix.indices);
814 assert_eq!(csc.data, matrix.data);
815 assert_eq!(csc.data_2, matrix.data_2);
816 }
817
818 /// A filtered read agrees with the chunk path: raw and pattern exactly,
819 /// norm once narrowed to the chunks' F16.
820 #[test]
821 fn test_read_genes_sparse_filtered_matches_chunk_path() {
822 let matrix = toy_csc();
823 let reader = InMemorySparseReader::new(&matrix, None).unwrap();
824 let genes = [2usize, 1, 0];
825 let keep: IndexSet<u32> = [3_u32, 0, 2].into_iter().collect();
826
827 let want = from_gene_chunks::<u32>(
828 reader.read_gene_parallel_filtered(&genes, &keep).unwrap(),
829 &DataLayerReturn::BothLayers,
830 keep.len(),
831 )
832 .unwrap();
833 let got = reader
834 .read_genes_sparse::<u32>(&genes, Some(&keep), &DataLayerReturn::BothLayers)
835 .unwrap();
836
837 assert_eq!(got.shape, want.shape);
838 assert_eq!(got.indptr, want.indptr);
839 assert_eq!(got.indices, want.indices);
840 assert_eq!(got.data, want.data);
841 let narrowed: Vec<f32> = got
842 .data_2
843 .unwrap()
844 .iter()
845 .map(|&v| F16::from_f32(v).to_f32())
846 .collect();
847 assert_eq!(narrowed, want.data_2.unwrap());
848 }
849
850 /// Reading every cell from the CSR twin is the twin itself; each mode
851 /// refuses the other axis and out-of-range runs error.
852 #[test]
853 fn test_read_sparse_modes_and_bounds() {
854 let matrix = toy_csc();
855 let gene_major = InMemorySparseReader::new(&matrix, None).unwrap();
856 let cell_major = InMemorySparseReader::new_cell_major(&matrix, None).unwrap();
857
858 let csr = cell_major
859 .read_cells_sparse::<u32>(&[0, 1, 2, 3], &DataLayerReturn::BothLayers)
860 .unwrap();
861 let twin = transpose_sparse(&matrix);
862 assert_eq!(csr.shape, twin.shape);
863 assert_eq!(csr.indptr, twin.indptr);
864 assert_eq!(csr.indices, twin.indices);
865 assert_eq!(csr.data, twin.data);
866 assert_eq!(csr.data_2, twin.data_2);
867
868 let layer = DataLayerReturn::Raw;
869 assert!(gene_major.read_cells_sparse::<u32>(&[0], &layer).is_err());
870 assert!(
871 cell_major
872 .read_genes_sparse::<u32>(&[0], None, &layer)
873 .is_err()
874 );
875 assert!(
876 gene_major
877 .read_genes_sparse::<u32>(&[3], None, &layer)
878 .is_err()
879 );
880 assert!(cell_major.read_cells_sparse::<u32>(&[4], &layer).is_err());
881 }
882}