Skip to main content

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}