Skip to main content

bixverse_sc_data/
data_io.rs

1//! Contains the streaming engine for bixverse single cell application. This
2//! has the structures for cells/spots and genes ready, plus various utilities,
3//! writers and readers.
4
5use bincode::{Decode, Encode, config, decode_from_slice, serde::encode_to_vec};
6use half::f16;
7use half::slice::HalfFloatSliceExt;
8use indexmap::IndexSet;
9use lz4_flex::block::{decompress_into, uncompressed_size};
10use lz4_flex::{compress_prepend_size, decompress_size_prepended};
11use memmap2::MmapOptions;
12use num_traits::FromPrimitive;
13use rayon::prelude::*;
14use rustc_hash::{FxHashMap, FxHashSet};
15use serde::{Deserialize, Serialize};
16use std::{
17    fs::File,
18    io::{BufWriter, Seek, SeekFrom, Write},
19    marker::Sync,
20    path::Path,
21    sync::Arc,
22};
23
24use crate::prelude::*;
25use crate::v4_io::{
26    ChunkCodec, ChunkFormat, V4Ctx, V4Decoded, V4Header, V4Run, decode_v4, decode_v4_into,
27    gene_norm_table, v4_cell_chunk, v4_gene_chunk,
28};
29
30///////////////////
31// Count wrapper //
32///////////////////
33
34/// Wrapper enum for raw counts that supports both u16 and u32 storage.
35///
36/// Most single cell data sets have counts well within the u16 range (0-65,535).
37/// The u32 variant exists for edge cases such as mixed-omics data where counts
38/// can exceed this limit. The variant is selected automatically at construction
39/// time based on the data, and is encoded as a discriminant byte in the binary
40/// chunk format.
41#[derive(Encode, Decode, Serialize, Deserialize, Debug, Clone)]
42pub enum RawCounts {
43    /// Stores u16 values
44    U16(Vec<u16>),
45    /// Stores u32 values (for the rare case where a single gene exceeds the
46    /// counts here)
47    U32(Vec<u32>),
48}
49
50/// Raw count element size discriminant for the binary format.
51/// Stored as a single byte in chunk headers.
52pub(crate) const RAW_ELEM_U16: u8 = 2;
53pub(crate) const RAW_ELEM_U32: u8 = 4;
54
55/// Header length of a CSR cell chunk: 4+4+4+8+8+4 bytes. See
56/// [`CsrCellChunk::write_to_bytes`] for the field layout.
57const CSR_CHUNK_HEADER_LEN: usize = 32;
58
59/// Header length of a CSC gene chunk: 4+4+4+2+2+8+8+4 bytes. See
60/// [`CscGeneChunk::write_to_bytes`] for the field layout.
61const CSC_CHUNK_HEADER_LEN: usize = 36;
62
63/// Flush cadence for gene-based writers, in chunks.
64///
65/// Gene chunks are far fewer and far larger than cell chunks, so a tight
66/// cadence keeps peak buffer occupancy bounded without costing many syscalls.
67const GENE_FLUSH_FREQUENCY: usize = 1_000;
68
69/// Flush cadence for cell-based writers, in chunks.
70///
71/// Cell chunks are small, so flushing this rarely effectively leaves the
72/// 128 MiB [`BufWriter`] to manage itself. Kept as a backstop only.
73const CELL_FLUSH_FREQUENCY: usize = 100_000;
74
75impl RawCounts {
76    /// Get a single value as u32
77    ///
78    /// ### Returns
79    ///
80    /// The count as u32
81    pub fn get(&self, i: usize) -> u32 {
82        match self {
83            RawCounts::U16(v) => v[i] as u32,
84            RawCounts::U32(v) => v[i],
85        }
86    }
87
88    /// Total number of elements
89    ///
90    /// ### Returns
91    ///
92    /// The length of elements
93    pub fn len(&self) -> usize {
94        match self {
95            RawCounts::U16(v) => v.len(),
96            RawCounts::U32(v) => v.len(),
97        }
98    }
99
100    /// Whether the vector is empty
101    ///
102    /// ### Returns
103    ///
104    /// Boolean if the data is empty
105    pub fn is_empty(&self) -> bool {
106        self.len() == 0
107    }
108
109    /// Iterator that yields all values as u32
110    ///
111    /// ### Returns
112    ///
113    /// An iterator
114    pub fn iter(&self) -> RawCountsIter<'_> {
115        RawCountsIter {
116            inner: self,
117            pos: 0,
118        }
119    }
120
121    /// Element size in bytes (2 for u16, 4 for u32)
122    ///
123    /// ### Returns
124    ///
125    /// The element size
126    pub fn elem_size(&self) -> u8 {
127        match self {
128            RawCounts::U16(_) => RAW_ELEM_U16,
129            RawCounts::U32(_) => RAW_ELEM_U32,
130        }
131    }
132
133    /// Write raw bytes to a writer
134    ///
135    /// ### Params
136    ///
137    /// * `writer` - The writer to use
138    ///
139    /// ### Returns
140    ///
141    /// The I/O result
142    pub fn write_bytes(&self, writer: &mut impl Write) -> std::io::Result<()> {
143        // Little-endian throughout, matching every header field in the format.
144        match self {
145            RawCounts::U16(v) => {
146                let mut bytes = Vec::with_capacity(v.len() * 2);
147                for x in v {
148                    bytes.extend_from_slice(&x.to_le_bytes());
149                }
150                writer.write_all(&bytes)
151            }
152            RawCounts::U32(v) => {
153                let mut bytes = Vec::with_capacity(v.len() * 4);
154                for x in v {
155                    bytes.extend_from_slice(&x.to_le_bytes());
156                }
157                writer.write_all(&bytes)
158            }
159        }
160    }
161
162    /// Read raw counts from a buffer given element size and count
163    ///
164    /// ### Params
165    ///
166    /// * `buffer` - The byte buffer to read from (must be positioned at the
167    ///   start of the raw count data)
168    /// * `count` - Number of elements to read
169    /// * `elem_size` - Element size in bytes (2 for u16, 4 for u32)
170    ///
171    /// ### Returns
172    ///
173    /// The `RawCounts`, or [`ScDataErrors::RawElemSizeInvalid`] if the
174    /// discriminant is unrecognised, or [`ScDataErrors::ChunkPayloadTruncated`]
175    /// if the buffer is shorter than `count * elem_size`.
176    pub fn read_from_buffer(
177        buffer: &[u8],
178        count: usize,
179        elem_size: u8,
180    ) -> Result<Self, ScDataErrors> {
181        // Zero covers legacy files where the discriminant byte was padding.
182        let width = match elem_size {
183            RAW_ELEM_U32 => 4_usize,
184            RAW_ELEM_U16 | 0 => 2_usize,
185            other => return Err(ScDataErrors::RawElemSizeInvalid(other)),
186        };
187
188        let needed = count * width;
189        if buffer.len() < needed {
190            return Err(ScDataErrors::ChunkPayloadTruncated {
191                expected: needed,
192                found: buffer.len(),
193            });
194        }
195
196        Ok(if width == 4 {
197            RawCounts::U32(
198                buffer[..needed]
199                    .chunks_exact(4)
200                    .map(|c| {
201                        u32::from_le_bytes(c.try_into().expect("4-byte chunk by construction"))
202                    })
203                    .collect(),
204            )
205        } else {
206            RawCounts::U16(
207                buffer[..needed]
208                    .chunks_exact(2)
209                    .map(|c| {
210                        u16::from_le_bytes(c.try_into().expect("2-byte chunk by construction"))
211                    })
212                    .collect(),
213            )
214        })
215    }
216
217    /// Build RawCounts from a u32 slice, auto-selecting the variant.
218    ///
219    /// If all values fit in u16, stores as U16 for better compression.
220    /// Otherwise stores as U32.
221    pub fn from_u32_auto(data: &[u32]) -> Self {
222        if data.iter().all(|&v| v <= u16::MAX as u32) {
223            RawCounts::U16(data.iter().map(|&v| v as u16).collect())
224        } else {
225            RawCounts::U32(data.to_vec())
226        }
227    }
228}
229
230/// Structure of the RawCountsIterator
231pub struct RawCountsIter<'a> {
232    /// The reference to the row counts
233    inner: &'a RawCounts,
234    /// The position
235    pos: usize,
236}
237
238impl<'a> Iterator for RawCountsIter<'a> {
239    type Item = u32;
240    fn next(&mut self) -> Option<u32> {
241        if self.pos < self.inner.len() {
242            let val = self.inner.get(self.pos);
243            self.pos += 1;
244            Some(val)
245        } else {
246            None
247        }
248    }
249}
250
251//////////////////
252// Cell Quality //
253//////////////////
254
255/// Structure to store QC information on cells
256#[derive(Clone, Debug)]
257pub struct CellQuality {
258    /// Indices of which cells to keep.
259    pub cell_indices: Vec<usize>,
260    /// Indices of which genes to keep
261    pub gene_indices: Vec<usize>,
262    /// Library size of cells
263    pub lib_size: Vec<usize>,
264    /// Number of cells expressing this gene
265    pub nnz: Vec<usize>,
266}
267
268impl CellQuality {
269    /// Update the internal cell indices
270    ///
271    /// ### Params
272    ///
273    /// * `cell_indices` - Vector of cell indices to keep
274    pub fn set_cell_indices(&mut self, cell_indices: &[usize]) {
275        self.cell_indices = cell_indices.to_vec();
276    }
277
278    /// Update the internal gene indices
279    ///
280    /// ### Params
281    ///
282    /// * `gene_indices` - Vector of gene indices to keep
283    pub fn set_gene_indices(&mut self, gene_indices: &[usize]) {
284        self.gene_indices = gene_indices.to_vec();
285    }
286}
287
288/// Structure that stores minimum QC thresholds/info for single cell
289#[derive(Clone, Debug)]
290pub struct MinCellQuality {
291    /// Minimum number of unique genes per cell (or spot for Visium type
292    /// technology).
293    pub min_unique_genes: usize,
294    /// Minimum library size per cell (or spot for Visium type technology).
295    pub min_lib_size: usize,
296    /// Minimum library size per cell (or spot for Visium type technology)/
297    pub min_cells: usize,
298    /// Target size for the library size normalisation (typical values 1e4 -
299    /// 1e5 single cell.
300    pub target_size: f32,
301}
302
303///////////////////////////
304// Sparse data streaming //
305///////////////////////////
306
307///////////////////////
308// CellOnFileQuality //
309///////////////////////
310
311/// CellOnFileQuality
312///
313/// This structure is being generate after a first scan of the file on disk and
314/// defining which cells and genes to actually read in.
315#[derive(Debug, Clone)]
316pub struct CellOnFileQuality {
317    /// Vector of indices of the cells to keep.
318    pub cells_to_keep: Vec<usize>,
319    /// Vector of indices of the genes to keep.
320    pub genes_to_keep: Vec<usize>,
321    /// HashSet of the indices to keep (for look-ups).
322    pub cells_to_keep_set: FxHashSet<usize>,
323    /// HashSet of the genes to keep (for look-ups).
324    pub genes_to_keep_set: FxHashSet<usize>,
325    /// Mapping of the old indices to the new indices for the cells.
326    pub cell_old_to_new: FxHashMap<usize, usize>,
327    /// Mapping of old indices to new indices for the genes.
328    pub gene_old_to_new: FxHashMap<usize, usize>,
329}
330
331impl CellOnFileQuality {
332    /// Create a new CellOnFileQuality structure
333    ///
334    /// ### Params
335    ///
336    /// * cells_to_keep - Index positions of the cells to keep
337    /// * genes_to_keep - Index positions of the genes to keep
338    ///
339    /// ### Returns
340    ///
341    /// Initiliased self
342    pub fn new(cells_to_keep: Vec<usize>, genes_to_keep: Vec<usize>) -> Self {
343        Self {
344            cells_to_keep,
345            genes_to_keep,
346            cells_to_keep_set: FxHashSet::default(),
347            genes_to_keep_set: FxHashSet::default(),
348            cell_old_to_new: FxHashMap::default(),
349            gene_old_to_new: FxHashMap::default(),
350        }
351    }
352
353    /// Generate internally the sets and maps
354    pub fn generate_maps_sets(&mut self) {
355        let cells_to_keep_set: FxHashSet<usize> = self.cells_to_keep.iter().cloned().collect();
356        let genes_to_keep_set: FxHashSet<usize> = self.genes_to_keep.iter().cloned().collect();
357        let cell_old_to_new: FxHashMap<usize, usize> = self
358            .cells_to_keep
359            .iter()
360            .enumerate()
361            .map(|(new_idx, &old_idx)| (old_idx, new_idx))
362            .collect();
363        let gene_old_to_new: FxHashMap<usize, usize> = self
364            .genes_to_keep
365            .iter()
366            .enumerate()
367            .map(|(new_idx, &old_idx)| (old_idx, new_idx))
368            .collect();
369
370        self.cells_to_keep_set = cells_to_keep_set;
371        self.genes_to_keep_set = genes_to_keep_set;
372        self.cell_old_to_new = cell_old_to_new;
373        self.gene_old_to_new = gene_old_to_new
374    }
375}
376
377//////////////////
378// CsrCellChunk //
379//////////////////
380
381/// The `data_norm` value of one count: `f16(ln1p(x / lib_size * target_size))`.
382///
383/// The single definition of the normalised layer. The archive drops the norm
384/// and recomputes it with this on restore, so any change here breaks bit
385/// identity with files written before it.
386///
387/// ### Params
388///
389/// * `x` - Raw count
390/// * `lib_size` - Library size of the cell
391/// * `target_size` - Target size of the normalisation
392///
393/// ### Returns
394///
395/// The normalised value.
396pub fn norm_value(x: f32, lib_size: f32, target_size: f32) -> F16 {
397    F16::from_f32((x / lib_size * target_size).ln_1p())
398}
399
400//////////////////
401// ChunkPayload //
402//////////////////
403
404/// Borrowed view over the three payload regions of one decompressed chunk.
405///
406/// Cell and gene chunks differ only in their headers; the payload is the same
407/// raw / norm / index run in both. This is the single parser for that run: the
408/// owned chunk types build their `Vec`s from it, and the fused batch readers
409/// walk it in place without allocating per chunk. Reads are byte-wise rather
410/// than pointer casts: the buffer is an lz4-decompressed `Vec<u8>`, so no
411/// region is guaranteed to be aligned.
412struct ChunkPayload<'a> {
413    /// Raw count bytes, at `raw_width` bytes per element.
414    data_raw: &'a [u8],
415    /// Number of raw count elements.
416    data_raw_len: usize,
417    /// Element width discriminant for `data_raw`, as stored in the header.
418    raw_elem_size: u8,
419    /// Bytes per raw count element, 2 or 4.
420    raw_width: usize,
421    /// Normalised count bytes, two per element.
422    data_norm: &'a [u8],
423    /// Index bytes, four per element.
424    indices: &'a [u8],
425}
426
427impl<'a> ChunkPayload<'a> {
428    /// Slice the payload regions that follow a chunk header.
429    ///
430    /// ### Params
431    ///
432    /// * `buffer` - One decompressed chunk, header included
433    /// * `header_len` - Length of the header the payload follows
434    /// * `lens` - The `(raw, norm, index)` element counts from the header
435    /// * `raw_elem_size` - Raw element width discriminant from the header
436    ///
437    /// ### Returns
438    ///
439    /// The payload, [`ScDataErrors::RawElemSizeInvalid`] for an unknown
440    /// width, or [`ScDataErrors::ChunkPayloadTruncated`] for a chunk shorter
441    /// than its own header claims.
442    fn slice(
443        buffer: &'a [u8],
444        header_len: usize,
445        lens: (usize, usize, usize),
446        raw_elem_size: u8,
447    ) -> Result<Self, ScDataErrors> {
448        let (data_raw_len, data_norm_len, indices_len) = lens;
449
450        // Zero covers legacy files where the discriminant byte was padding.
451        let raw_width = match raw_elem_size {
452            RAW_ELEM_U32 => 4,
453            RAW_ELEM_U16 | 0 => 2,
454            other => return Err(ScDataErrors::RawElemSizeInvalid(other)),
455        };
456
457        let data_end = header_len + data_raw_len * raw_width;
458        let norm_end = data_end + data_norm_len * 2;
459        let total_end = norm_end + indices_len * 4;
460
461        // The three length fields are untrusted, so validate the total payload
462        // before taking any slice.
463        if buffer.len() < total_end {
464            return Err(ScDataErrors::ChunkPayloadTruncated {
465                expected: total_end,
466                found: buffer.len(),
467            });
468        }
469
470        Ok(Self {
471            data_raw: &buffer[header_len..data_end],
472            data_raw_len,
473            raw_elem_size,
474            raw_width,
475            data_norm: &buffer[data_end..norm_end],
476            indices: &buffer[norm_end..total_end],
477        })
478    }
479
480    /// Iterate the normalised layer.
481    ///
482    /// ### Returns
483    ///
484    /// An iterator over the stored f16 values.
485    fn norm_iter(&self) -> impl Iterator<Item = F16> + '_ {
486        self.data_norm
487            .chunks_exact(2)
488            .map(|c| F16::from_le_bytes(c.try_into().expect("2-byte chunk by construction")))
489    }
490
491    /// Iterate the indices.
492    ///
493    /// ### Returns
494    ///
495    /// An iterator over the stored indices.
496    fn index_iter(&self) -> impl Iterator<Item = u32> + '_ {
497        self.indices
498            .chunks_exact(4)
499            .map(|c| u32::from_le_bytes(c.try_into().expect("4-byte chunk by construction")))
500    }
501
502    /// Copy the raw layer out as owned [`RawCounts`].
503    ///
504    /// ### Returns
505    ///
506    /// The raw counts at their stored width.
507    fn raw_counts(&self) -> Result<RawCounts, ScDataErrors> {
508        RawCounts::read_from_buffer(self.data_raw, self.data_raw_len, self.raw_elem_size)
509    }
510}
511
512///////////////
513// RunSource //
514///////////////
515
516/// Entries per stack block when widening f16 to f32 in bulk. Big enough for
517/// `half`'s slice conversion to run its vector path, small enough to stay on
518/// the stack.
519const F16_BLOCK: usize = 64;
520
521/// One stored run (a cell row or a gene column) as the fused readers consume
522/// it: random access for the filtered path, bulk extends for the unfiltered
523/// one.
524pub(crate) trait RunSource {
525    /// Number of stored entries.
526    ///
527    /// ### Returns
528    ///
529    /// The run length.
530    fn nnz(&self) -> usize;
531
532    /// Stored index of entry `i`.
533    ///
534    /// ### Params
535    ///
536    /// * `i` - Entry position
537    ///
538    /// ### Returns
539    ///
540    /// The gene (cell run) or cell (gene run) index.
541    fn index_at(&self, i: usize) -> u32;
542
543    /// Raw count of entry `i`.
544    ///
545    /// ### Params
546    ///
547    /// * `i` - Entry position
548    ///
549    /// ### Returns
550    ///
551    /// The count.
552    fn raw_at(&self, i: usize) -> u32;
553
554    /// Normalised value of entry `i`.
555    ///
556    /// ### Params
557    ///
558    /// * `i` - Entry position
559    ///
560    /// ### Returns
561    ///
562    /// The value as f32.
563    fn norm_at(&self, i: usize) -> f32;
564
565    /// Append every stored index.
566    ///
567    /// ### Params
568    ///
569    /// * `out` - Destination
570    fn extend_indices(&self, out: &mut Vec<u32>);
571
572    /// Append every raw count, converted to `T`.
573    ///
574    /// ### Params
575    ///
576    /// * `out` - Destination
577    ///
578    /// ### Returns
579    ///
580    /// `Ok(())`, or [`ScDataErrors::RawCountOverflow`] for a count that does
581    /// not fit `T`. Nothing is appended on error.
582    fn extend_raw<T: FromPrimitive>(&self, out: &mut Vec<T>) -> Result<(), ScDataErrors>;
583
584    /// Append every normalised value.
585    ///
586    /// ### Params
587    ///
588    /// * `out` - Destination
589    fn extend_norm(&self, out: &mut Vec<f32>);
590}
591
592/// Append raw counts converted to `T`, checking overflow once on the maximum.
593///
594/// Every `FromPrimitive` target used for counts (u16, u32, f32, f64) is
595/// monotone over `u32`, so if the maximum fits, every value does. That keeps
596/// the branch out of the copy loop.
597///
598/// ### Params
599///
600/// * `out` - Destination
601/// * `vals` - The counts, iterated twice
602///
603/// ### Returns
604///
605/// `Ok(())`, or [`ScDataErrors::RawCountOverflow`] for the maximum.
606#[inline]
607pub(crate) fn extend_raw_checked<T, I>(out: &mut Vec<T>, vals: I) -> Result<(), ScDataErrors>
608where
609    T: FromPrimitive,
610    I: Iterator<Item = u32> + Clone,
611{
612    raw_to::<T>(vals.clone().max().unwrap_or(0))?;
613    out.extend(vals.map(|v| T::from_u32(v).expect("the maximum fits, so every value does")));
614    Ok(())
615}
616
617impl RunSource for ChunkPayload<'_> {
618    #[inline]
619    fn nnz(&self) -> usize {
620        self.indices.len() / 4
621    }
622
623    #[inline(always)]
624    fn index_at(&self, i: usize) -> u32 {
625        let b = &self.indices[i * 4..i * 4 + 4];
626        u32::from_le_bytes([b[0], b[1], b[2], b[3]])
627    }
628
629    #[inline(always)]
630    fn raw_at(&self, i: usize) -> u32 {
631        if self.raw_width == 4 {
632            let b = &self.data_raw[i * 4..i * 4 + 4];
633            u32::from_le_bytes([b[0], b[1], b[2], b[3]])
634        } else {
635            let b = &self.data_raw[i * 2..i * 2 + 2];
636            u16::from_le_bytes([b[0], b[1]]) as u32
637        }
638    }
639
640    #[inline(always)]
641    fn norm_at(&self, i: usize) -> f32 {
642        let b = &self.data_norm[i * 2..i * 2 + 2];
643        F16::from_le_bytes([b[0], b[1]]).to_f32()
644    }
645
646    fn extend_indices(&self, out: &mut Vec<u32>) {
647        out.extend(self.index_iter());
648    }
649
650    fn extend_raw<T: FromPrimitive>(&self, out: &mut Vec<T>) -> Result<(), ScDataErrors> {
651        if self.raw_width == 4 {
652            extend_raw_checked(
653                out,
654                self.data_raw.chunks_exact(4).map(|c| {
655                    u32::from_le_bytes(c.try_into().expect("4-byte chunk by construction"))
656                }),
657            )
658        } else {
659            extend_raw_checked(
660                out,
661                self.data_raw.chunks_exact(2).map(|c| {
662                    u16::from_le_bytes(c.try_into().expect("2-byte chunk by construction")) as u32
663                }),
664            )
665        }
666    }
667
668    fn extend_norm(&self, out: &mut Vec<f32>) {
669        let start = out.len();
670        out.resize(start + self.data_norm.len() / 2, 0.0);
671        let mut block = [f16::ZERO; F16_BLOCK];
672        for (src, dst) in self
673            .data_norm
674            .chunks(2 * F16_BLOCK)
675            .zip(out[start..].chunks_mut(F16_BLOCK))
676        {
677            for (b, c) in block.iter_mut().zip(src.chunks_exact(2)) {
678                *b = f16::from_le_bytes(c.try_into().expect("2-byte chunk by construction"));
679            }
680            block[..dst.len()].convert_to_f32_slice(dst);
681        }
682    }
683}
684
685/// Read a little-endian `u32` length field from a chunk header.
686///
687/// ### Params
688///
689/// * `header` - The header bytes
690/// * `at` - Byte offset of the field
691///
692/// ### Returns
693///
694/// The field as `usize`.
695#[inline]
696fn header_u32(header: &[u8], at: usize) -> usize {
697    u32::from_le_bytes([header[at], header[at + 1], header[at + 2], header[at + 3]]) as usize
698}
699
700//////////////////
701// CsrChunkView //
702//////////////////
703
704/// Borrowed view over one decompressed CSR cell chunk.
705///
706/// The cell header fields plus the shared [`ChunkPayload`]. Every payload
707/// slice is bounds-checked against the buffer before it is taken, so the three
708/// untrusted length fields in the header cannot index out of range.
709struct CsrChunkView<'a> {
710    /// The raw / norm / gene index regions.
711    payload: ChunkPayload<'a>,
712    /// Total library size of the cell.
713    library_size: usize,
714    /// Original index of the cell in the experiment.
715    original_index: usize,
716    /// Whether the cell passed QC.
717    to_keep: bool,
718}
719
720impl<'a> CsrChunkView<'a> {
721    /// Parse the chunk header and slice out the three payload regions.
722    ///
723    /// ### Params
724    ///
725    /// * `buffer` - One decompressed chunk
726    ///
727    /// ### Returns
728    ///
729    /// The view, or [`ScDataErrors::ChunkBufferTooSmall`] /
730    /// [`ScDataErrors::ChunkPayloadTruncated`] for a chunk shorter than its
731    /// own header claims.
732    fn parse(buffer: &'a [u8]) -> Result<Self, ScDataErrors> {
733        if buffer.len() < CSR_CHUNK_HEADER_LEN {
734            return Err(ScDataErrors::ChunkBufferTooSmall {
735                expected: CSR_CHUNK_HEADER_LEN,
736                found: buffer.len(),
737            });
738        }
739
740        let header = &buffer[0..CSR_CHUNK_HEADER_LEN];
741        let lens = (
742            header_u32(header, 0),
743            header_u32(header, 4),
744            header_u32(header, 8),
745        );
746
747        Ok(Self {
748            payload: ChunkPayload::slice(buffer, CSR_CHUNK_HEADER_LEN, lens, header[29])?,
749            library_size: le_u64_at(header, 12) as usize,
750            original_index: le_u64_at(header, 20) as usize,
751            to_keep: header[28] != 0,
752        })
753    }
754}
755
756/// Parse a CSR cell chunk down to its payload.
757///
758/// ### Params
759///
760/// * `buffer` - One decompressed cell chunk
761///
762/// ### Returns
763///
764/// The payload, or the errors of [`CsrChunkView::parse`].
765fn csr_payload(buffer: &[u8]) -> Result<ChunkPayload<'_>, ScDataErrors> {
766    CsrChunkView::parse(buffer).map(|view| view.payload)
767}
768
769/// Parse a CSC gene chunk down to its payload.
770///
771/// The gene header fields (`avg_exp`, `nnz`, `original_index`, `to_keep`) are
772/// not needed by the fused readers, so they are skipped rather than decoded.
773///
774/// ### Params
775///
776/// * `buffer` - One decompressed gene chunk
777///
778/// ### Returns
779///
780/// The payload, or [`ScDataErrors::ChunkBufferTooSmall`] /
781/// [`ScDataErrors::ChunkPayloadTruncated`] for a chunk shorter than its own
782/// header claims.
783fn csc_payload(buffer: &[u8]) -> Result<ChunkPayload<'_>, ScDataErrors> {
784    if buffer.len() < CSC_CHUNK_HEADER_LEN {
785        return Err(ScDataErrors::ChunkBufferTooSmall {
786            expected: CSC_CHUNK_HEADER_LEN,
787            found: buffer.len(),
788        });
789    }
790
791    let lens = (
792        header_u32(buffer, 0),
793        header_u32(buffer, 4),
794        header_u32(buffer, 8),
795    );
796
797    ChunkPayload::slice(buffer, CSC_CHUNK_HEADER_LEN, lens, buffer[33])
798}
799
800/// CsrCellChunk
801///
802/// This structure is designed to store the data of a single cell in a
803/// CSR-like format optimised for rapid access on disk.
804///
805/// Raw counts are stored as a `RawCounts` enum which transparently handles
806/// both u16 and u32 storage. Gene indices are stored as u32 to support
807/// data sets with more than 65,535 features.
808#[derive(Debug)]
809pub struct CsrCellChunk {
810    /// Raw counts for this cell, stored as either u16 or u32 depending on
811    /// the data range
812    pub data_raw: RawCounts,
813    /// Vector of the norm counts of this cell. A lossy compression for f16 is
814    /// applied.
815    pub data_norm: Vec<F16>,
816    /// Total library size/UMI counts of the cell.
817    pub library_size: usize,
818    /// Index positions of the genes (u32 to support >65k features)
819    pub indices: Vec<u32>,
820    /// Original index in the data
821    pub original_index: usize,
822    /// Flag if the cell should be kept. (Not used at the moment.)
823    pub to_keep: bool,
824}
825
826impl CsrCellChunk {
827    /// Function to generate the chunk from R data
828    ///
829    /// Assumes columns = genes, rows = cells.
830    ///
831    /// ### Params
832    ///
833    /// * `data` - The raw counts present in this cell
834    /// * `col_idx` - The column indices where the gene is expressed.
835    /// * `original_index` - Original row index in the matrix
836    /// * `size_factor` - To which size to normalise to. 1e6 -> CPM normalisation.
837    /// * `to_keep` - Whether the cell passes QC
838    ///
839    /// ### Returns
840    ///
841    /// The `CsrCellChunk` for this cell.
842    pub fn from_data<T, U>(
843        data: &[T],
844        col_idx: &[U],
845        original_index: usize,
846        size_factor: f32,
847        to_keep: bool,
848    ) -> Self
849    where
850        T: FloatAndUInt,
851        U: FloatAndUInt,
852    {
853        // one sequential f32 accumulator: the sum feeds the normalisation, so
854        // its rounding is part of the stored values
855        let sum = data.iter().map(|&x| x.to_f32()).sum::<f32>();
856        let data_norm: Vec<F16> = data
857            .iter()
858            .map(|&x| norm_value(x.to_f32(), sum, size_factor))
859            .collect();
860
861        let data_raw = if data.iter().all(|&x| x.to_u32() <= u16::MAX as u32) {
862            RawCounts::U16(data.iter().map(|&x| x.to_u32() as u16).collect())
863        } else {
864            RawCounts::U32(data.iter().map(|&x| x.to_u32()).collect())
865        };
866
867        Self {
868            data_raw,
869            data_norm,
870            library_size: sum as usize,
871            indices: col_idx.iter().map(|&x| x.to_u32()).collect::<Vec<u32>>(),
872            original_index,
873            to_keep,
874        }
875    }
876
877    /// Write directly to bytes on disk
878    ///
879    /// Binary layout (header = 32 bytes):
880    ///
881    /// ```text
882    /// [0..4)   data_raw_len    u32
883    /// [4..8)   data_norm_len   u32
884    /// [8..12)  indices_len     u32
885    /// [12..20) library_size    u64
886    /// [20..28) original_index  u64
887    /// [28]     to_keep         u8
888    /// [29]     raw_elem_size   u8  (2 = u16, 4 = u32)
889    /// [30..32) padding         2 bytes
890    /// ```
891    ///
892    /// Followed by:
893    /// - data_raw: `data_raw_len * raw_elem_size` bytes
894    /// - data_norm: `data_norm_len * 2` bytes
895    /// - indices: `indices_len * 4` bytes (u32)
896    pub fn write_to_bytes(&self, writer: &mut impl Write) -> std::io::Result<()> {
897        writer.write_all(&self.to_bytes())
898    }
899
900    /// Serialise into one exactly sized buffer, in the layout of
901    /// [`Self::write_to_bytes`].
902    ///
903    /// ### Returns
904    ///
905    /// The serialised chunk.
906    fn to_bytes(&self) -> Vec<u8> {
907        let raw_bytes = self.data_raw.len() * self.data_raw.elem_size() as usize;
908        let mut buffer = Vec::with_capacity(
909            CSR_CHUNK_HEADER_LEN + raw_bytes + self.data_norm.len() * 2 + self.indices.len() * 4,
910        );
911
912        buffer.extend_from_slice(&(self.data_raw.len() as u32).to_le_bytes());
913        buffer.extend_from_slice(&(self.data_norm.len() as u32).to_le_bytes());
914        buffer.extend_from_slice(&(self.indices.len() as u32).to_le_bytes());
915        buffer.extend_from_slice(&(self.library_size as u64).to_le_bytes());
916        buffer.extend_from_slice(&(self.original_index as u64).to_le_bytes());
917        buffer.extend_from_slice(&[self.to_keep as u8, self.data_raw.elem_size(), 0, 0]);
918
919        match &self.data_raw {
920            RawCounts::U16(v) => {
921                for x in v {
922                    buffer.extend_from_slice(&x.to_le_bytes());
923                }
924            }
925            RawCounts::U32(v) => {
926                for x in v {
927                    buffer.extend_from_slice(&x.to_le_bytes());
928                }
929            }
930        }
931        for v in &self.data_norm {
932            buffer.extend_from_slice(&v.to_bits().to_le_bytes());
933        }
934        for i in &self.indices {
935            buffer.extend_from_slice(&i.to_le_bytes());
936        }
937
938        buffer
939    }
940
941    /// Serialise and lz4-compress the chunk into the on-disk payload.
942    ///
943    /// Pure with respect to the writer, so callers can build many payloads in
944    /// parallel and hand them to
945    /// [`CellGeneSparseWriter::write_compressed_cell_chunks`] in order.
946    ///
947    /// ### Returns
948    ///
949    /// The compressed payload, size-prepended as the reader expects.
950    pub fn to_compressed_bytes(&self) -> std::io::Result<Vec<u8>> {
951        Ok(compress_prepend_size(&self.to_bytes()))
952    }
953
954    /// Read data from buffer
955    ///
956    /// ### Params
957    ///
958    /// * `buffer` - A slice of u8's representing the buffer
959    ///
960    /// ### Return
961    ///
962    /// The `CsrCellChunk`
963    pub fn read_from_buffer(buffer: &[u8]) -> Result<Self, ScDataErrors> {
964        let view = CsrChunkView::parse(buffer)?;
965
966        Ok(Self {
967            data_raw: view.payload.raw_counts()?,
968            data_norm: view.payload.norm_iter().collect(),
969            library_size: view.library_size,
970            indices: view.payload.index_iter().collect(),
971            original_index: view.original_index,
972            to_keep: view.to_keep,
973        })
974    }
975
976    /// Generate a vector of Chunks from CompressedSparseData2
977    ///
978    /// ### Params
979    ///
980    /// * `sparse_data` - The `CompressedSparseData2` (in CSR format!)
981    /// * `cell_qc` - Minimum QC thresholds
982    ///
983    /// ### Returns
984    ///
985    /// A tuple of the `Vec<CsrCellChunk>` and if the cell should be kept.
986    pub fn generate_chunks_sparse_data<T, U>(
987        sparse_data: CompressedSparseData2<T, U>,
988        cell_qc: MinCellQuality,
989    ) -> (Vec<CsrCellChunk>, CellQuality)
990    where
991        T: Clone + Default + Into<u32> + Sync,
992        U: Clone + Default,
993    {
994        let n_cells = sparse_data.indptr.len() - 1;
995        let n_genes = sparse_data.shape.1;
996
997        // count how many cells express each gene
998        let mut no_cells_exp_gene = vec![0usize; n_genes];
999        for i in 0..n_cells {
1000            let start_i = sparse_data.indptr[i] as usize;
1001            let end_i = sparse_data.indptr[i + 1] as usize;
1002            for &gene_idx in &sparse_data.indices[start_i..end_i] {
1003                no_cells_exp_gene[gene_idx as usize] += 1;
1004            }
1005        }
1006
1007        // filter genes
1008        let genes_to_keep: Vec<usize> = (0..n_genes)
1009            .filter(|&i| no_cells_exp_gene[i] >= cell_qc.min_cells)
1010            .collect();
1011
1012        // create index mapping: old gene index -> new gene index
1013        let mut gene_index_map = vec![None; n_genes];
1014        for (new_idx, &old_idx) in genes_to_keep.iter().enumerate() {
1015            gene_index_map[old_idx] = Some(new_idx);
1016        }
1017
1018        // Process cells
1019        let results: Vec<_> = (0..n_cells)
1020            .into_par_iter()
1021            .map(|i| {
1022                let start_i = sparse_data.indptr[i] as usize;
1023                let end_i = sparse_data.indptr[i + 1] as usize;
1024
1025                let mut filtered_data = Vec::new();
1026                let mut filtered_indices = Vec::new();
1027
1028                for idx in start_i..end_i {
1029                    let old_gene_idx = sparse_data.indices[idx] as usize;
1030                    if let Some(new_gene_idx) = gene_index_map[old_gene_idx] {
1031                        filtered_data.push(sparse_data.data[idx].clone().into());
1032                        filtered_indices.push(new_gene_idx);
1033                    }
1034                }
1035
1036                let sum_data_i = filtered_data.iter().sum::<u32>() as usize;
1037                let nnz_i = filtered_indices.len();
1038
1039                let to_keep_i =
1040                    (nnz_i >= cell_qc.min_unique_genes) && (sum_data_i >= cell_qc.min_lib_size);
1041
1042                let chunk_i = CsrCellChunk::from_data(
1043                    &filtered_data,
1044                    &filtered_indices,
1045                    i,
1046                    cell_qc.target_size,
1047                    to_keep_i,
1048                );
1049
1050                (chunk_i, sum_data_i, nnz_i, to_keep_i, i)
1051            })
1052            .collect();
1053
1054        let mut res = Vec::with_capacity(n_cells);
1055        let mut lib_size = Vec::new();
1056        let mut nnz = Vec::new();
1057        let mut cells_to_keep = Vec::new();
1058
1059        for (chunk, lib, n, keep, idx) in results {
1060            res.push(chunk);
1061            if keep {
1062                lib_size.push(lib);
1063                nnz.push(n);
1064                cells_to_keep.push(idx);
1065            }
1066        }
1067
1068        let qc_data = CellQuality {
1069            cell_indices: cells_to_keep,
1070            gene_indices: genes_to_keep,
1071            lib_size,
1072            nnz,
1073        };
1074
1075        (res, qc_data)
1076    }
1077
1078    /// Helper function to get QC parameters for this cell
1079    ///
1080    /// ### Returns
1081    ///
1082    /// A tuple of `(no_genes, library_size)`
1083    pub fn get_qc_info(&self) -> (usize, usize) {
1084        (self.indices.len(), self.library_size)
1085    }
1086
1087    /// Filter to keep only specified genes
1088    ///
1089    /// This will remove the genes that are not set to `true` in the boolean
1090    /// vector. This assumes that the indices of the genes and the booleans
1091    /// are the same!
1092    ///
1093    /// ### Params
1094    ///
1095    /// * `genes_to_keep` - Keep only the genes within this boolean vectors.
1096    pub fn filter_genes(&mut self, genes_to_keep: &[bool]) {
1097        let mut index_map = vec![None; genes_to_keep.len()];
1098        let mut new_idx = 0;
1099        for (old_idx, &keep) in genes_to_keep.iter().enumerate() {
1100            if keep {
1101                index_map[old_idx] = Some(new_idx);
1102                new_idx += 1;
1103            }
1104        }
1105
1106        let mut new_indices = Vec::new();
1107        let mut new_norm = Vec::new();
1108        let mut keep_positions = Vec::new();
1109
1110        for (i, &gene_idx) in self.indices.iter().enumerate() {
1111            if let Some(new_gene_idx) = index_map[gene_idx as usize] {
1112                new_indices.push(new_gene_idx);
1113                new_norm.push(self.data_norm[i]);
1114                keep_positions.push(i);
1115            }
1116        }
1117
1118        let new_raw = match &self.data_raw {
1119            RawCounts::U16(v) => RawCounts::U16(keep_positions.iter().map(|&p| v[p]).collect()),
1120            RawCounts::U32(v) => RawCounts::U32(keep_positions.iter().map(|&p| v[p]).collect()),
1121        };
1122
1123        self.indices = new_indices;
1124        self.data_raw = new_raw;
1125        self.data_norm = new_norm;
1126    }
1127}
1128
1129//////////////////
1130// CscGeneChunk //
1131//////////////////
1132
1133/// CscGeneChunk
1134///
1135/// This structure is designed to store the data of a single gene in a
1136/// CSC-like format optimised for rapid access on disk.
1137///
1138/// Raw counts are stored as a `RawCounts` enum (u16 or u32). Cell indices
1139/// are stored as u32.
1140#[derive(Encode, Decode, Serialize, Deserialize, Debug)]
1141pub struct CscGeneChunk {
1142    /// Raw counts per cell/spot for this gene, stored as either u16 or u32
1143    pub data_raw: RawCounts,
1144    /// Normalised counts per cell/spot for this gene. Lossy compression to
1145    /// f16 is applied.
1146    pub data_norm: Vec<F16>,
1147    /// Average expression of this gene in the data.
1148    pub avg_exp: F16,
1149    /// Number of cells expressing this gene
1150    pub nnz: usize,
1151    /// Indices of the cells expressing this gene
1152    pub indices: Vec<u32>,
1153    /// Original index from the data
1154    pub original_index: usize,
1155    /// Flag to indicate if gene shall be kept. Not in use at the moment.
1156    pub to_keep: bool,
1157}
1158
1159impl CscGeneChunk {
1160    /// Helper function to generate the CscGeneChunk from converted data
1161    ///
1162    /// ### Params
1163    ///
1164    /// * `data_raw` - The raw counts for this gene
1165    /// * `data_norm` - The normalised counts for this gene.
1166    /// * `col_idx` - The column indices for which cells this gene is expressed.
1167    /// * `original_index` - Original row index
1168    /// * `to_keep` - Shall the gene be included in later analysis.
1169    ///
1170    /// ### Returns
1171    ///
1172    /// The `CscGeneChunk` for this gene.
1173    pub fn from_conversion(
1174        data_raw: RawCounts,
1175        data_norm: &[F16],
1176        col_idx: &[usize],
1177        original_index: usize,
1178        to_keep: bool,
1179    ) -> Self {
1180        // Accumulate in f32 and narrow once, as `pca.rs` already does. Summing
1181        // in f16 biases the total upwards: past 64 the f16 step is 0.0625, so
1182        // small addends round up every time and a thousand-cell gene overshoots
1183        // by roughly 5%. This value is written into the chunk header, so the
1184        // drift was being persisted.
1185        let avg_exp = F16::from_f32(data_norm.iter().map(|v| v.to_f32()).sum::<f32>());
1186        let nnz = data_raw.len();
1187
1188        Self {
1189            data_raw,
1190            data_norm: data_norm.to_vec(),
1191            avg_exp,
1192            nnz,
1193            indices: col_idx.iter().map(|x| *x as u32).collect::<Vec<u32>>(),
1194            original_index,
1195            to_keep,
1196        }
1197    }
1198
1199    /// Write directly to bytes on disk
1200    ///
1201    /// Binary layout (header = 36 bytes):
1202    ///
1203    /// ```text
1204    /// [0..4)   data_raw_len    u32
1205    /// [4..8)   data_norm_len   u32
1206    /// [8..12)  indices_len     u32
1207    /// [12..14) avg_exp         F16 (2 bytes)
1208    /// [14..16) padding         2 bytes
1209    /// [16..24) nnz             u64
1210    /// [24..32) original_index  u64
1211    /// [32]     to_keep         u8
1212    /// [33]     raw_elem_size   u8  (2 = u16, 4 = u32)
1213    /// [34..36) padding         2 bytes
1214    /// ```
1215    ///
1216    /// Followed by:
1217    /// - data_raw: `data_raw_len * raw_elem_size` bytes
1218    /// - data_norm: `data_norm_len * 2` bytes
1219    /// - indices: `indices_len * 4` bytes (u32)
1220    pub fn write_to_bytes(&self, writer: &mut impl Write) -> std::io::Result<()> {
1221        writer.write_all(&(self.data_raw.len() as u32).to_le_bytes())?;
1222        writer.write_all(&(self.data_norm.len() as u32).to_le_bytes())?;
1223        writer.write_all(&(self.indices.len() as u32).to_le_bytes())?;
1224        writer.write_all(&self.avg_exp.to_le_bytes())?;
1225        // first padding
1226        writer.write_all(&[0, 0])?;
1227        writer.write_all(&(self.nnz as u64).to_le_bytes())?;
1228        writer.write_all(&(self.original_index as u64).to_le_bytes())?;
1229        writer.write_all(&[self.to_keep as u8, self.data_raw.elem_size(), 0, 0])?;
1230
1231        self.data_raw.write_bytes(writer)?;
1232
1233        let mut data_norm_bytes = Vec::with_capacity(self.data_norm.len() * 2);
1234        for v in &self.data_norm {
1235            data_norm_bytes.extend_from_slice(&v.to_bits().to_le_bytes());
1236        }
1237        writer.write_all(&data_norm_bytes)?;
1238
1239        let mut row_indices_bytes = Vec::with_capacity(self.indices.len() * 4);
1240        for i in &self.indices {
1241            row_indices_bytes.extend_from_slice(&i.to_le_bytes());
1242        }
1243        writer.write_all(&row_indices_bytes)?;
1244
1245        Ok(())
1246    }
1247
1248    /// Serialise and lz4-compress the chunk into the on-disk payload.
1249    ///
1250    /// Pure with respect to the writer, so callers can build many payloads in
1251    /// parallel and hand them to
1252    /// [`CellGeneSparseWriter::write_compressed_gene_chunks`] in order.
1253    ///
1254    /// ### Returns
1255    ///
1256    /// The compressed payload, size-prepended as the reader expects.
1257    pub fn to_compressed_bytes(&self) -> std::io::Result<Vec<u8>> {
1258        let mut buffer = Vec::new();
1259        self.write_to_bytes(&mut buffer)?;
1260        Ok(compress_prepend_size(&buffer))
1261    }
1262
1263    /// Read data from buffer
1264    ///
1265    /// ### Params
1266    ///
1267    /// * `buffer` - A slice of u8's representing the buffer
1268    ///
1269    /// ### Return
1270    ///
1271    /// The `CscGeneChunk`
1272    pub fn read_from_buffer(buffer: &[u8]) -> Result<Self, ScDataErrors> {
1273        let payload = csc_payload(buffer)?;
1274
1275        Ok(Self {
1276            data_raw: payload.raw_counts()?,
1277            data_norm: payload.norm_iter().collect(),
1278            avg_exp: F16::from_le_bytes([buffer[12], buffer[13]]),
1279            nnz: le_u64_at(buffer, 16) as usize,
1280            indices: payload.index_iter().collect(),
1281            original_index: le_u64_at(buffer, 24) as usize,
1282            to_keep: buffer[32] != 0,
1283        })
1284    }
1285
1286    /// Helper function that allows to filter out cells
1287    ///
1288    /// Kept cells are renumbered to their position in `cells_to_keep`, and the
1289    /// output is ordered by that position (PCA and NEBULA rely on it).
1290    ///
1291    /// ### Params
1292    ///
1293    /// * `cells_to_keep` - IndexSet with cell index positions to keep.
1294    pub fn filter_selected_cells(&mut self, cells_to_keep: &IndexSet<u32>) {
1295        self.filter_cells_by(|cell| cells_to_keep.get_index_of(&cell).map(|i| i as u32));
1296    }
1297
1298    /// Filter to the cells a dense index map keeps.
1299    ///
1300    /// Same result as [`Self::filter_selected_cells`], but each stored entry
1301    /// costs an array load rather than a hash probe. Build the map once per
1302    /// read with [`dense_index_map`] and share it across genes.
1303    ///
1304    /// ### Params
1305    ///
1306    /// * `cell_map` - `cell_map[cell] = output row`, [`INDEX_DROPPED`] or out
1307    ///   of range for cells not kept.
1308    pub fn filter_selected_cells_dense(&mut self, cell_map: &[u32]) {
1309        self.filter_cells_by(|cell| match cell_map.get(cell as usize) {
1310            Some(&row) if row != INDEX_DROPPED => Some(row),
1311            _ => None,
1312        });
1313    }
1314
1315    /// Keep the entries whose cell maps to an output row, renumbered to it.
1316    ///
1317    /// One walk over this gene's non-zeros. Stored cell indices ascend, so the
1318    /// kept rows only need sorting when the keep set itself is not ascending.
1319    ///
1320    /// ### Params
1321    ///
1322    /// * `row_of` - Output row for a cell, `None` to drop it.
1323    #[inline]
1324    fn filter_cells_by(&mut self, row_of: impl Fn(u32) -> Option<u32>) {
1325        let mut kept: Vec<(u32, u32)> = self
1326            .indices
1327            .iter()
1328            .enumerate()
1329            .filter_map(|(pos, &cell)| row_of(cell).map(|row| (row, pos as u32)))
1330            .collect();
1331
1332        if !kept.is_sorted_by_key(|&(row, _)| row) {
1333            kept.sort_unstable_by_key(|&(row, _)| row);
1334        }
1335
1336        self.data_norm = kept
1337            .iter()
1338            .map(|&(_, p)| self.data_norm[p as usize])
1339            .collect();
1340        self.data_raw = match &self.data_raw {
1341            RawCounts::U16(v) => RawCounts::U16(kept.iter().map(|&(_, p)| v[p as usize]).collect()),
1342            RawCounts::U32(v) => RawCounts::U32(kept.iter().map(|&(_, p)| v[p as usize]).collect()),
1343        };
1344        self.indices = kept.iter().map(|&(row, _)| row).collect();
1345        self.nnz = kept.len();
1346    }
1347
1348    /// Calculate the average gene expression given a set of cells
1349    ///
1350    /// ### Params
1351    ///
1352    /// * `cells_to_keep` - IndexSet with cell index positions to keep.
1353    ///
1354    /// ### Returns
1355    ///
1356    /// Tuple of `(gene_index, average expression)`.
1357    pub fn calculate_avg_exp(&self, cells_to_keep: &IndexSet<u32>) -> (usize, f32) {
1358        let sum: f32 = self
1359            .indices
1360            .iter()
1361            .zip(self.data_norm.iter())
1362            .filter(|(cell_idx, _)| cells_to_keep.contains(*cell_idx))
1363            .map(|(_, val)| val.to_f32())
1364            .sum();
1365        let avg = sum / cells_to_keep.len() as f32;
1366
1367        (self.original_index, avg)
1368    }
1369
1370    /// Transform the chunk to a sparse Axis of CSC type
1371    ///
1372    /// ### Params
1373    ///
1374    /// * `n_cells` - Number of cells represented in the data
1375    ///
1376    /// ### Returns
1377    ///
1378    /// `SparseAxis` with u32 in the main slot and f32 in the data_2 layer.
1379    pub fn to_sparse_axis(&self, n_cells: usize) -> SparseAxis<u32, f32> {
1380        let raw_u32: Vec<u32> = self.data_raw.iter().collect();
1381        SparseAxis::new_csc(
1382            self.indices.iter().map(|x| *x as usize).collect(),
1383            raw_u32,
1384            Some(self.data_norm.iter().map(|x| x.to_f32()).collect()),
1385            n_cells,
1386        )
1387    }
1388}
1389
1390/// SparseDataHeader
1391///
1392/// Stores the information in terms of total cells, total genes, number of
1393/// chunks in terms of cells and genes and the offset vectors
1394#[derive(Encode, Decode, Serialize, Deserialize, Clone)]
1395pub struct SparseDataHeader {
1396    /// Total number of cells in the experiment.
1397    pub total_cells: usize,
1398    /// Total number of genes in the experiemnt.
1399    pub total_genes: usize,
1400    /// Is the file written in a way for fast cell data retrieval (set to true)
1401    /// or for fast gene retrieval (set to false)
1402    pub cell_based: bool,
1403    /// Number of chunks in this file storing either the cell or gene data
1404    pub no_chunks: usize,
1405    /// Offset vector for reading in the data
1406    pub chunk_offsets: Vec<u64>,
1407    /// FxHashMap with the original index -> chunk info
1408    pub index_map: FxHashMap<usize, usize>,
1409}
1410
1411/// Leading bytes identifying a bixverse single-cell binary. Checked on open so
1412/// a foreign file is refused rather than parsed as one of ours.
1413const FILE_MAGIC: &[u8; 8] = b"SCRNASEQ";
1414
1415/// The little-endian `u64` at byte `at`.
1416fn le_u64_at(bytes: &[u8], at: usize) -> u64 {
1417    u64::from_le_bytes(
1418        bytes[at..at + 8]
1419            .try_into()
1420            .expect("an 8-byte slice converts to [u8; 8]"),
1421    )
1422}
1423
1424/// Fixed-size file header that points to the main header location
1425#[repr(C)]
1426#[derive(Encode, Decode, Serialize, Deserialize)]
1427struct FileHeader {
1428    /// Magic string as bytes to recognise the file
1429    magic: [u8; 8],
1430    /// Version of the file
1431    version: u32,
1432    /// Offset of the main header, i.e., 64 bytes
1433    main_header_offset: u64,
1434    /// Is cell-based (CSR format)
1435    cell_based: bool,
1436    /// Library size the `data_norm` layer was normalised against. Carved out
1437    /// of the reserved block, so files predating this field decode it as
1438    /// `0.0`, which is the "unknown" sentinel (a real target size is never 0).
1439    target_size: f32,
1440    /// Offset of the library-size index (cell-based files). Carved out of the
1441    /// reserved block like `target_size`, so older files read `0`: no index,
1442    /// and readers fall back to the chunk headers.
1443    library_index_offset: u64,
1444    /// 20 additional reserved bytes for the future
1445    _reserved_1: [u8; 20],
1446    /// 3 additional reserved bytes for the future
1447    _reserved_2: [u8; 3],
1448}
1449
1450impl FileHeader {
1451    /// Generate a new header
1452    ///
1453    /// ### Params
1454    ///
1455    /// * `cell_based` - Is the data stored for fast cell retrieval.
1456    /// * `target_size` - Library size the normalised layer was scaled to. Pass
1457    ///   `0.0` on raw-only paths where no normalisation was applied.
1458    ///
1459    /// ### Returns
1460    ///
1461    /// A new object of `FileHeader`
1462    fn new(cell_based: bool, target_size: f32) -> Self {
1463        Self {
1464            magic: *FILE_MAGIC,
1465            version: SC_FILE_VERSION,
1466            main_header_offset: 0,
1467            cell_based,
1468            target_size,
1469            library_index_offset: 0,
1470            _reserved_1: [0; 20],
1471            _reserved_2: [0; 3],
1472        }
1473    }
1474}
1475
1476//////////////////////
1477// Streaming writer //
1478//////////////////////
1479
1480/// A compressed cell chunk for
1481/// [`CellGeneSparseWriter::write_compressed_cell_chunks`]. The library size
1482/// travels with it so the writer can build the library-size index without
1483/// decompressing anything.
1484pub struct CellPayload {
1485    /// Original index of the cell in the data.
1486    pub original_index: usize,
1487    /// Library size of the cell, as stored in the chunk header.
1488    pub library_size: usize,
1489    /// Size-prepended lz4 payload.
1490    pub bytes: Vec<u8>,
1491}
1492
1493/// CellGeneSparseWriter
1494///
1495/// Implementation of a structure for writing in a streamed manner two different
1496/// types of sparse stored data.
1497pub struct CellGeneSparseWriter {
1498    /// The header of the file.
1499    header: SparseDataHeader,
1500    /// Buffered writer to the file.
1501    writer: BufWriter<File>,
1502    /// The current position of the chunks.
1503    chunks_start_pos: u64,
1504    /// Boolean indicating if the writer is designed to write in an efficient
1505    /// manner for cells.
1506    cell_based: bool,
1507    /// The chunks written since the last flush
1508    chunks_since_flush: usize,
1509    /// The frequency of flushing, see [`CELL_FLUSH_FREQUENCY`] and
1510    /// [`GENE_FLUSH_FREQUENCY`].
1511    flush_frequency: usize,
1512    /// Library size the `data_norm` layer was normalised against. Written into
1513    /// the [`FileHeader`] on finalisation, which rebuilds the header from
1514    /// scratch, so the value has to survive on the writer.
1515    target_size: f32,
1516    /// Library size of every cell chunk, in chunk order. Written as the
1517    /// library-size index on finalisation (cell-based files only).
1518    library_sizes: Vec<u64>,
1519    /// File format version written into the header.
1520    version: u32,
1521    /// Body codec of v4 chunks, see [`ChunkFormat`].
1522    codec: ChunkCodec,
1523    /// Library size per original cell index, written as the cell table of a
1524    /// v4 gene-based file on finalisation.
1525    cell_library_table: Option<Vec<u64>>,
1526}
1527
1528impl CellGeneSparseWriter {
1529    /// Create a new sparse writer instance
1530    ///
1531    /// Writes the current file format (v4, lz4 bodies). Use
1532    /// [`Self::with_codec`] to pick another body codec, or [`Self::new_v3`]
1533    /// for the legacy format.
1534    ///
1535    /// This writer assumes that rows represent genes and columns represent
1536    /// cells.
1537    ///
1538    /// ### Params
1539    ///
1540    /// * `path_f` - Path to the .bin file to which to write to.
1541    /// * `cell_based` - Shall the writer be set up for writing cell-based
1542    ///   (`true`) or gene-based chunks.
1543    /// * `total_cells` - Total cells in the data.
1544    /// * `total_genes` - Total genes in the data.
1545    /// * `target_size` - Library size the `data_norm` layer was normalised
1546    ///   against. Pass `0.0` on raw-only paths where no normalisation applies;
1547    ///   readers report that as `None`.
1548    ///
1549    /// ### Returns
1550    ///
1551    /// The `CellGeneSparseWriter`.
1552    pub fn new<P: AsRef<Path>>(
1553        path_f: P,
1554        cell_based: bool,
1555        total_cells: usize,
1556        total_genes: usize,
1557        target_size: f32,
1558    ) -> Result<Self, ScDataErrors> {
1559        let file = File::create(path_f)?;
1560        let mut writer = BufWriter::with_capacity(128 * 1024 * 1024, file);
1561
1562        let file_header = FileHeader::new(cell_based, target_size);
1563        let file_header_enc = encode_to_vec(&file_header, config::standard())
1564            .map_err(|_| ScDataErrors::HeaderEncodeFailed)?;
1565        if file_header_enc.len() < 64 {
1566            writer.write_all(&file_header_enc)?;
1567            writer.write_all(&vec![0u8; 64 - file_header_enc.len()])?;
1568        } else {
1569            writer.write_all(&file_header_enc[..64])?;
1570        }
1571        writer.flush()?;
1572
1573        let chunks_start_pos = 64;
1574
1575        let flush_frequency = if cell_based {
1576            CELL_FLUSH_FREQUENCY
1577        } else {
1578            GENE_FLUSH_FREQUENCY
1579        };
1580
1581        let header = SparseDataHeader {
1582            total_cells,
1583            total_genes,
1584            cell_based,
1585            no_chunks: 0,
1586            chunk_offsets: Vec::new(),
1587            index_map: FxHashMap::default(),
1588        };
1589
1590        Ok(Self {
1591            header,
1592            writer,
1593            chunks_start_pos,
1594            cell_based,
1595            chunks_since_flush: 0_usize,
1596            flush_frequency,
1597            target_size,
1598            library_sizes: Vec::new(),
1599            version: SC_FILE_VERSION,
1600            codec: ChunkCodec::Lz4,
1601            cell_library_table: None,
1602        })
1603    }
1604
1605    /// Create a writer for the legacy v3 format.
1606    ///
1607    /// Only needed to produce v3 files on purpose, such as the input of a
1608    /// conversion test. Same parameters as [`Self::new`].
1609    ///
1610    /// ### Params
1611    ///
1612    /// * `path_f` - Path to the .bin file to which to write to.
1613    /// * `cell_based` - Cell-based (`true`) or gene-based chunks.
1614    /// * `total_cells` - Total cells in the data.
1615    /// * `total_genes` - Total genes in the data.
1616    /// * `target_size` - Library size the normalised layer is scaled to.
1617    ///
1618    /// ### Returns
1619    ///
1620    /// The `CellGeneSparseWriter`.
1621    pub fn new_v3<P: AsRef<Path>>(
1622        path_f: P,
1623        cell_based: bool,
1624        total_cells: usize,
1625        total_genes: usize,
1626        target_size: f32,
1627    ) -> Result<Self, ScDataErrors> {
1628        let mut writer = Self::new(path_f, cell_based, total_cells, total_genes, target_size)?;
1629        writer.version = SC_FILE_VERSION_V3;
1630        Ok(writer)
1631    }
1632
1633    /// Set the body codec of the v4 chunks this writer encodes.
1634    ///
1635    /// Chunks that do not shrink are stored uncompressed whatever the codec.
1636    ///
1637    /// ### Params
1638    ///
1639    /// * `codec` - The codec
1640    ///
1641    /// ### Returns
1642    ///
1643    /// The writer, with the codec set.
1644    pub fn with_codec(mut self, codec: ChunkCodec) -> Self {
1645        self.codec = codec;
1646        self
1647    }
1648
1649    /// How this writer encodes chunks.
1650    ///
1651    /// Parallel payload builders copy this out and encode with it, so what
1652    /// they hand to the `write_compressed_*` methods matches the file.
1653    ///
1654    /// ### Returns
1655    ///
1656    /// The format: file version, body codec and the header target size.
1657    pub fn format(&self) -> ChunkFormat {
1658        ChunkFormat {
1659            version: self.version,
1660            codec: self.codec,
1661            target_size: self.target_size,
1662        }
1663    }
1664
1665    /// Set the per-cell library-size table of a v4 gene-based file.
1666    ///
1667    /// Gene chunks that drop their norm layer recompute it from the library
1668    /// size of each cell, so the table has to be set before
1669    /// [`Self::finalise`]. The chunks must have been encoded against the same
1670    /// table, see [`ChunkFormat::encode_gene`].
1671    ///
1672    /// ### Params
1673    ///
1674    /// * `table` - Library size per original cell index
1675    ///
1676    /// ### Returns
1677    ///
1678    /// `Ok(())`, or an error unless this is a v4 gene-based writer.
1679    pub fn set_cell_library_table(&mut self, table: Vec<u64>) -> Result<(), ScDataErrors> {
1680        if self.version != SC_FILE_VERSION {
1681            return Err(ScDataErrors::FileVersionMismatch {
1682                expected: SC_FILE_VERSION,
1683                found: self.version,
1684            });
1685        }
1686        self.check_mode(false)?;
1687        self.cell_library_table = Some(table);
1688        Ok(())
1689    }
1690
1691    /// Verify the writer was opened in the orientation the caller expects.
1692    ///
1693    /// ### Params
1694    ///
1695    /// * `want_cell_based` - `true` when a cell (CSR) chunk is being written.
1696    ///
1697    /// ### Returns
1698    ///
1699    /// `Ok(())`, or [`ScDataErrors::ReaderModeMismatch`] on a mismatch.
1700    fn check_mode(&self, want_cell_based: bool) -> Result<(), ScDataErrors> {
1701        if self.cell_based == want_cell_based {
1702            return Ok(());
1703        }
1704        let label = |b: bool| if b { "cell-based" } else { "gene-based" };
1705        Err(ScDataErrors::ReaderModeMismatch {
1706            actual: label(self.cell_based),
1707            requested: label(want_cell_based),
1708        })
1709    }
1710
1711    /// Append an already compressed chunk and record its offset.
1712    ///
1713    /// Flushes the underlying [`BufWriter`] every `flush_frequency` chunks.
1714    ///
1715    /// ### Params
1716    ///
1717    /// * `compressed` - The encoded chunk, in the format of this writer.
1718    /// * `original_index` - Original index of the cell or gene in the data.
1719    ///
1720    /// ### Returns
1721    ///
1722    /// `Ok(())` on success, an I/O error otherwise.
1723    fn append_compressed(
1724        &mut self,
1725        compressed: &[u8],
1726        original_index: usize,
1727    ) -> Result<(), ScDataErrors> {
1728        let current_pos = self.writer.stream_position()?;
1729        let chunk_offset = current_pos - self.chunks_start_pos;
1730        self.header.chunk_offsets.push(chunk_offset);
1731        self.header
1732            .index_map
1733            .insert(original_index, self.header.no_chunks);
1734
1735        self.writer
1736            .write_all(&(compressed.len() as u64).to_le_bytes())?;
1737        self.writer.write_all(compressed)?;
1738
1739        self.header.no_chunks += 1;
1740        self.chunks_since_flush += 1;
1741
1742        if self.chunks_since_flush >= self.flush_frequency {
1743            self.writer.flush()?;
1744            self.chunks_since_flush = 0;
1745        }
1746
1747        Ok(())
1748    }
1749
1750    /// Append a compressed cell chunk and record its library size.
1751    ///
1752    /// ### Params
1753    ///
1754    /// * `compressed` - Size-prepended lz4 payload of a serialised cell chunk.
1755    /// * `original_index` - Original index of the cell in the data.
1756    /// * `library_size` - Library size stored in the chunk header.
1757    ///
1758    /// ### Returns
1759    ///
1760    /// `Ok(())` on success, an I/O error otherwise.
1761    fn append_cell(
1762        &mut self,
1763        compressed: &[u8],
1764        original_index: usize,
1765        library_size: usize,
1766    ) -> Result<(), ScDataErrors> {
1767        self.append_compressed(compressed, original_index)?;
1768        self.library_sizes.push(library_size as u64);
1769        Ok(())
1770    }
1771
1772    /// Write a Cell (Chunk) to the file
1773    ///
1774    /// The data is represented in a CSR-type format.
1775    ///
1776    /// ### Params
1777    ///
1778    /// * `cell_chunk` - The data representing that specific cell.
1779    ///
1780    /// ### Returns
1781    ///
1782    /// `Ok(())`, or [`ScDataErrors::ReaderModeMismatch`] if the writer was
1783    /// opened for gene-based chunks.
1784    pub fn write_cell_chunk(&mut self, cell_chunk: CsrCellChunk) -> Result<(), ScDataErrors> {
1785        self.check_mode(true)?;
1786
1787        let bytes = self.format().encode_cell(&cell_chunk)?;
1788        self.append_cell(&bytes, cell_chunk.original_index, cell_chunk.library_size)
1789    }
1790
1791    /// Write a Gene to the file
1792    ///
1793    /// A v4 gene chunk written this way keeps its normalised layer verbatim,
1794    /// since there is no per-cell library table to recompute it from. The bulk
1795    /// path, `write_gene_file`, drops it.
1796    ///
1797    /// ### Params
1798    ///
1799    /// * `gene_chunk` - The data representing that specific gene.
1800    ///
1801    /// ### Returns
1802    ///
1803    /// `Ok(())`, or [`ScDataErrors::ReaderModeMismatch`] if the writer was
1804    /// opened for cell-based chunks.
1805    pub fn write_gene_chunk(&mut self, gene_chunk: CscGeneChunk) -> Result<(), ScDataErrors> {
1806        self.check_mode(false)?;
1807
1808        let bytes = self.format().encode_gene(&gene_chunk, &[])?;
1809        self.append_compressed(&bytes, gene_chunk.original_index)
1810    }
1811
1812    /// Write genes whose payloads were already built via
1813    /// [`ChunkFormat::encode_gene`] of this writer's [`Self::format`].
1814    ///
1815    /// ### Params
1816    ///
1817    /// * `chunks` - `(original_index, payload)` pairs, appended in the given
1818    ///   order.
1819    ///
1820    /// ### Returns
1821    ///
1822    /// `Ok(())`, or [`ScDataErrors::ReaderModeMismatch`] if the writer was
1823    /// opened for cell-based chunks.
1824    pub fn write_compressed_gene_chunks(
1825        &mut self,
1826        chunks: &[(usize, Vec<u8>)],
1827    ) -> Result<(), ScDataErrors> {
1828        self.check_mode(false)?;
1829        self.append_compressed_batch(chunks)
1830    }
1831
1832    /// Write cells whose payloads were already built via
1833    /// [`ChunkFormat::encode_cell`] of this writer's [`Self::format`].
1834    ///
1835    /// ### Params
1836    ///
1837    /// * `chunks` - Cell payloads, appended in the given order.
1838    ///
1839    /// ### Returns
1840    ///
1841    /// `Ok(())`, or [`ScDataErrors::ReaderModeMismatch`] if the writer was
1842    /// opened for gene-based chunks.
1843    pub fn write_compressed_cell_chunks(
1844        &mut self,
1845        chunks: &[CellPayload],
1846    ) -> Result<(), ScDataErrors> {
1847        self.check_mode(true)?;
1848        for chunk in chunks {
1849            self.append_cell(&chunk.bytes, chunk.original_index, chunk.library_size)?;
1850        }
1851        Ok(())
1852    }
1853
1854    /// Append a batch of compressed payloads in order.
1855    ///
1856    /// ### Params
1857    ///
1858    /// * `chunks` - `(original_index, payload)` pairs.
1859    ///
1860    /// ### Returns
1861    ///
1862    /// `Ok(())` on success, an I/O error otherwise.
1863    fn append_compressed_batch(&mut self, chunks: &[(usize, Vec<u8>)]) -> Result<(), ScDataErrors> {
1864        for (original_index, payload) in chunks {
1865            self.append_compressed(payload, *original_index)?;
1866        }
1867        Ok(())
1868    }
1869
1870    /// Finalise the file
1871    ///
1872    /// Writes the [`SparseDataHeader`] tail and, for cell-based files, the
1873    /// library-size index ([`Self::write_library_index`]), then rewrites the
1874    /// fixed 64-byte file header at offset zero with both offsets.
1875    ///
1876    /// ### Returns
1877    ///
1878    /// `Ok(())` on success, [`ScDataErrors::HeaderEncodeFailed`] if either
1879    /// header could not be encoded.
1880    pub fn finalise(mut self) -> Result<(), ScDataErrors> {
1881        // write header size and header
1882        let main_header_offset = self.writer.stream_position()?;
1883
1884        let header_data = encode_to_vec(&self.header, config::standard())
1885            .map_err(|_| ScDataErrors::HeaderEncodeFailed)?;
1886        let header_size = header_data.len() as u64;
1887
1888        self.writer.write_all(&header_size.to_le_bytes())?;
1889        self.writer.write_all(&header_data)?;
1890
1891        let mut file_header = FileHeader::new(self.cell_based, self.target_size);
1892        file_header.version = self.version;
1893        file_header.main_header_offset = main_header_offset;
1894        if self.cell_based {
1895            file_header.library_index_offset = self.write_library_index()?;
1896        } else if let Some(table) = self.cell_library_table.take() {
1897            file_header.library_index_offset = self.write_library_table(&table)?;
1898        }
1899
1900        self.writer.seek(SeekFrom::Start(0))?;
1901        let file_header_enc = encode_to_vec(&file_header, config::standard())
1902            .map_err(|_| ScDataErrors::HeaderEncodeFailed)?;
1903
1904        // ensure it's exactly 64 bytes
1905        if file_header_enc.len() < 64 {
1906            self.writer.write_all(&file_header_enc)?;
1907            self.writer
1908                .write_all(&vec![0u8; 64 - file_header_enc.len()])?;
1909        } else {
1910            self.writer.write_all(&file_header_enc[..64])?;
1911        }
1912
1913        self.writer.flush()?;
1914
1915        Ok(())
1916    }
1917
1918    /// Write the library-size index at the current position: a `u64` count,
1919    /// then one `u64` per cell chunk in chunk order.
1920    ///
1921    /// ### Returns
1922    ///
1923    /// The offset the index starts at, for the [`FileHeader`].
1924    fn write_library_index(&mut self) -> Result<u64, ScDataErrors> {
1925        assert!(self.cell_based, "library-size index on a gene-based file");
1926        assert_eq!(
1927            self.library_sizes.len(),
1928            self.header.no_chunks,
1929            "library-size index has {} entries for {} cell chunks",
1930            self.library_sizes.len(),
1931            self.header.no_chunks
1932        );
1933        let offset = self.writer.stream_position()?;
1934        self.writer
1935            .write_all(&(self.library_sizes.len() as u64).to_le_bytes())?;
1936        for library_size in &self.library_sizes {
1937            self.writer.write_all(&library_size.to_le_bytes())?;
1938        }
1939        Ok(offset)
1940    }
1941
1942    /// Write the per-cell library-size table of a v4 gene file at the current
1943    /// position: a `u64` count, then one `u64` per original cell index.
1944    ///
1945    /// ### Params
1946    ///
1947    /// * `table` - Library size per original cell index
1948    ///
1949    /// ### Returns
1950    ///
1951    /// The offset the table starts at, for the [`FileHeader`].
1952    fn write_library_table(&mut self, table: &[u64]) -> Result<u64, ScDataErrors> {
1953        let offset = self.writer.stream_position()?;
1954        self.writer.write_all(&(table.len() as u64).to_le_bytes())?;
1955        for library_size in table {
1956            self.writer.write_all(&library_size.to_le_bytes())?;
1957        }
1958        Ok(offset)
1959    }
1960
1961    /// Update the number of cells in the header
1962    ///
1963    /// Helper function to update the number of cells to the number that was
1964    /// finally written on disk.
1965    ///
1966    /// ### Params
1967    ///
1968    /// * `no_cells` - New number of cells
1969    pub fn update_header_no_cells(&mut self, no_cells: usize) {
1970        self.header.total_cells = no_cells;
1971    }
1972
1973    /// Update the number of cells in the header
1974    ///
1975    /// Helper function to update the number of genes to the number that was
1976    /// finally written on disk.
1977    ///
1978    /// ### Params
1979    ///
1980    /// * `no_genes` - New number of genes
1981    pub fn update_header_no_genes(&mut self, no_genes: usize) {
1982        self.header.total_genes = no_genes;
1983    }
1984}
1985
1986/////////////////////////
1987// Parallel cell write //
1988/////////////////////////
1989
1990/// Marker in a dense index map for a cell or gene that is dropped.
1991pub const INDEX_DROPPED: u32 = u32::MAX;
1992
1993/// Cells built and compressed per parallel batch in [`write_cell_rows`].
1994///
1995/// Large enough to amortise the rayon fan-out, small enough that the
1996/// compressed payloads held before the append stay a few MB.
1997const CELL_WRITE_BATCH: usize = 4_096;
1998
1999/// Dense old-to-new map from the kept cell or gene indices.
2000///
2001/// Replaces a hash lookup per stored value with an index.
2002///
2003/// ### Params
2004///
2005/// * `kept` - Old indices to keep, in new-index order
2006///
2007/// ### Returns
2008///
2009/// `map[old] = new`, [`INDEX_DROPPED`] for indices not kept. Sized to the
2010/// largest kept index, so callers treat out-of-range as dropped.
2011pub fn dense_index_map(kept: &[usize]) -> Vec<u32> {
2012    let mut map = vec![INDEX_DROPPED; kept.iter().max().map_or(0, |&g| g + 1)];
2013    for (new, &old) in kept.iter().enumerate() {
2014        map[old] = new as u32;
2015    }
2016    map
2017}
2018
2019/// [`dense_index_map`] over a keep set, rows in the set's iteration order.
2020///
2021/// ### Params
2022///
2023/// * `cells_to_keep` - Cells to keep, in output order
2024///
2025/// ### Returns
2026///
2027/// `map[cell] = row`, [`INDEX_DROPPED`] for cells not kept.
2028pub fn keep_set_index_map(cells_to_keep: &IndexSet<u32>) -> Vec<u32> {
2029    let kept: Vec<usize> = cells_to_keep.iter().map(|&c| c as usize).collect();
2030    dense_index_map(&kept)
2031}
2032
2033/// Dense gene map from a per-file `local -> final` mapping.
2034///
2035/// ### Params
2036///
2037/// * `mapping` - `Some(final)` for kept local genes, `None` otherwise
2038///
2039/// ### Returns
2040///
2041/// `map[local] = final`, [`INDEX_DROPPED`] for dropped genes.
2042pub fn dense_gene_map_from_options(mapping: &[Option<usize>]) -> Vec<u32> {
2043    mapping
2044        .iter()
2045        .map(|g| g.map_or(INDEX_DROPPED, |g| g as u32))
2046        .collect()
2047}
2048
2049/// Build one cell chunk from `(gene, count)` pairs and compress it.
2050///
2051/// ### Params
2052///
2053/// * `row` - The cell's `(gene, count)` pairs, sorted by gene here if needed
2054/// * `original_index` - Index the cell is stored under
2055/// * `target_size` - Target size for the library normalisation
2056/// * `format` - The format of the writer the payload goes to
2057///
2058/// ### Returns
2059///
2060/// `(nnz, payload)`; the payload carries the library size.
2061pub fn compress_cell_row(
2062    row: &mut [(u32, u32)],
2063    original_index: usize,
2064    target_size: f32,
2065    format: ChunkFormat,
2066) -> Result<(usize, CellPayload), ScDataErrors> {
2067    if row.windows(2).any(|w| w[0].0 > w[1].0) {
2068        row.sort_by_key(|&(g, _)| g);
2069    }
2070    let genes: Vec<u32> = row.iter().map(|&(g, _)| g).collect();
2071    let counts: Vec<u32> = row.iter().map(|&(_, c)| c).collect();
2072    let chunk = CsrCellChunk::from_data(&counts, &genes, original_index, target_size, true);
2073    let (nnz, library_size) = chunk.get_qc_info();
2074    let payload = CellPayload {
2075        original_index,
2076        library_size,
2077        bytes: format.encode_cell(&chunk)?,
2078    };
2079    Ok((nnz, payload))
2080}
2081
2082/// Write accumulated per-cell rows to the cell-based binary.
2083///
2084/// For the paths that have to gather a cell's values from a gene-major or
2085/// unsorted source (CSC h5ad, mtx) before writing. Rows are sorted, built and
2086/// compressed in parallel batches of [`CELL_WRITE_BATCH`], appended in order,
2087/// and freed as they go.
2088///
2089/// ### Params
2090///
2091/// * `rows` - One `(gene, count)` row per cell, in output order
2092/// * `cell_offset` - Index of the first row's cell in the output
2093/// * `target_size` - Target size for the library normalisation
2094/// * `writer` - Cell-based writer to append to
2095///
2096/// ### Returns
2097///
2098/// `(nnz, lib_size)` per written cell.
2099pub fn write_cell_rows(
2100    rows: &mut [Vec<(u32, u32)>],
2101    cell_offset: usize,
2102    target_size: f32,
2103    writer: &mut CellGeneSparseWriter,
2104) -> Result<(Vec<usize>, Vec<usize>), ScDataErrors> {
2105    let mut nnz = Vec::with_capacity(rows.len());
2106    let mut lib_size = Vec::with_capacity(rows.len());
2107    let format = writer.format();
2108
2109    for (batch_idx, batch) in rows.chunks_mut(CELL_WRITE_BATCH).enumerate() {
2110        let first = cell_offset + batch_idx * CELL_WRITE_BATCH;
2111        let built = batch
2112            .par_iter_mut()
2113            .enumerate()
2114            .map(|(k, row)| {
2115                let mut row = std::mem::take(row);
2116                compress_cell_row(&mut row, first + k, target_size, format)
2117            })
2118            .collect::<Result<Vec<_>, ScDataErrors>>()?;
2119
2120        let mut payloads = Vec::with_capacity(built.len());
2121        for (nnz_i, payload) in built {
2122            nnz.push(nnz_i);
2123            lib_size.push(payload.library_size);
2124            payloads.push(payload);
2125        }
2126        writer.write_compressed_cell_chunks(&payloads)?;
2127    }
2128
2129    Ok((nnz, lib_size))
2130}
2131
2132/// Build, compress and append cell chunks in parallel batches.
2133///
2134/// For writers whose chunks come from a closure, so that neither the build nor
2135/// the lz4 compression runs on the single writer thread. Batches of
2136/// [`CELL_WRITE_BATCH`] are compressed in parallel and appended in order.
2137///
2138/// ### Params
2139///
2140/// * `n_chunks` - Number of chunks to write
2141/// * `build` - Builds chunk `k` in `0..n_chunks`, its `original_index` set
2142/// * `writer` - Cell-based writer to append to
2143///
2144/// ### Returns
2145///
2146/// `(nnz, lib_size)` per written cell.
2147pub fn write_cell_chunks_built<F>(
2148    n_chunks: usize,
2149    build: F,
2150    writer: &mut CellGeneSparseWriter,
2151) -> Result<(Vec<usize>, Vec<usize>), ScDataErrors>
2152where
2153    F: Fn(usize) -> CsrCellChunk + Sync,
2154{
2155    let mut nnz = Vec::with_capacity(n_chunks);
2156    let mut lib_size = Vec::with_capacity(n_chunks);
2157    let format = writer.format();
2158
2159    for start in (0..n_chunks).step_by(CELL_WRITE_BATCH) {
2160        let end = (start + CELL_WRITE_BATCH).min(n_chunks);
2161        let built = (start..end)
2162            .into_par_iter()
2163            .map(|k| {
2164                let chunk = build(k);
2165                let (nnz_i, lib_i) = chunk.get_qc_info();
2166                Ok::<_, ScDataErrors>((
2167                    nnz_i,
2168                    CellPayload {
2169                        original_index: chunk.original_index,
2170                        library_size: lib_i,
2171                        bytes: format.encode_cell(&chunk)?,
2172                    },
2173                ))
2174            })
2175            .collect::<Result<Vec<_>, _>>()?;
2176
2177        let mut payloads = Vec::with_capacity(built.len());
2178        for (nnz_i, payload) in built {
2179            nnz.push(nnz_i);
2180            lib_size.push(payload.library_size);
2181            payloads.push(payload);
2182        }
2183        writer.write_compressed_cell_chunks(&payloads)?;
2184    }
2185
2186    Ok((nnz, lib_size))
2187}
2188
2189/// Compress and append already built cell chunks in parallel batches.
2190///
2191/// ### Params
2192///
2193/// * `chunks` - Chunks to write, in output order
2194/// * `writer` - Cell-based writer to append to
2195///
2196/// ### Returns
2197///
2198/// `Ok(())`, or the first compression or I/O error.
2199pub fn write_cell_chunks_parallel(
2200    chunks: &[CsrCellChunk],
2201    writer: &mut CellGeneSparseWriter,
2202) -> Result<(), ScDataErrors> {
2203    let format = writer.format();
2204    for batch in chunks.chunks(CELL_WRITE_BATCH) {
2205        let payloads = batch
2206            .par_iter()
2207            .map(|chunk| {
2208                Ok::<_, ScDataErrors>(CellPayload {
2209                    original_index: chunk.original_index,
2210                    library_size: chunk.library_size,
2211                    bytes: format.encode_cell(chunk)?,
2212                })
2213            })
2214            .collect::<Result<Vec<_>, _>>()?;
2215        writer.write_compressed_cell_chunks(&payloads)?;
2216    }
2217    Ok(())
2218}
2219
2220///////////////////////
2221// SingleCellReading //
2222///////////////////////
2223
2224/// Read access to a chunked single cell count store.
2225///
2226/// Abstracts over where the data physically lives. The only implementor today
2227/// is [`ParallelSparseReader`], which memory-maps the bixverse binary format
2228/// from disk, but the trait exposes nothing file-specific so that other
2229/// backends can be added later without touching consumers.
2230///
2231/// Whether a store is cell-based (CSR, fast per-cell access) or gene-based
2232/// (CSC, fast per-gene access) is a runtime property of the underlying data,
2233/// not of the type. Asking a gene-based store for cells returns
2234/// [`ScDataErrors::ReaderModeMismatch`].
2235///
2236/// Implementors need only supply `read_cells_parallel`, `read_gene_parallel`,
2237/// `get_header` and `is_cell_based`. The rest have default bodies written in
2238/// terms of those four. Backends that can do better should override: the
2239/// defaults for `read_cell_library_sizes` and `read_gene_nnz` parse whole
2240/// chunks, whereas a backend able to peek at chunk headers can avoid that.
2241///
2242/// `Send + Sync` is required: readers are shared across `rayon` threads.
2243pub trait SingleCellReading: Send + Sync {
2244    /// Read cells by index in a multi-threaded manner
2245    ///
2246    /// ### Params
2247    ///
2248    /// * `indices` - Index positions of the cells to retrieve
2249    ///
2250    /// ### Returns
2251    ///
2252    /// The `CsrCellChunk`s in the order given by `indices`.
2253    fn read_cells_parallel(&self, indices: &[usize]) -> Result<Vec<CsrCellChunk>, ScDataErrors>;
2254
2255    /// Read genes by index in a multi-threaded manner
2256    ///
2257    /// ### Params
2258    ///
2259    /// * `indices` - Index positions of the genes to retrieve
2260    ///
2261    /// ### Returns
2262    ///
2263    /// The `CscGeneChunk`s in the order given by `indices`.
2264    fn read_gene_parallel(&self, indices: &[usize]) -> Result<Vec<CscGeneChunk>, ScDataErrors>;
2265
2266    /// Read genes for consumers of the raw counts and indices only.
2267    ///
2268    /// `data_norm` may come back empty, so only use it when the consumer never
2269    /// touches that layer. The default reads both layers.
2270    ///
2271    /// ### Params
2272    ///
2273    /// * `indices` - Index positions of the genes to retrieve
2274    ///
2275    /// ### Returns
2276    ///
2277    /// The `CscGeneChunk`s in the order given by `indices`.
2278    fn read_gene_parallel_raw(&self, indices: &[usize]) -> Result<Vec<CscGeneChunk>, ScDataErrors> {
2279        self.read_gene_parallel(indices)
2280    }
2281
2282    /// Read a batch of cells straight into one CSR matrix.
2283    ///
2284    /// Same output as [`Self::read_cells_parallel`] followed by
2285    /// [`from_cell_chunks`], without materialising a chunk per cell. Prefer it
2286    /// whenever the consumer only needs the counts and indices.
2287    ///
2288    /// ### Params
2289    ///
2290    /// * `indices` - Cell indices, in output row order
2291    /// * `data_layer` - Which layer(s) to materialise
2292    ///
2293    /// ### Returns
2294    ///
2295    /// CSR of shape `(indices.len(), total_genes)`, raw counts in `data` (empty
2296    /// when not requested) and normalised counts in `data_2`, or
2297    /// [`ScDataErrors::ReaderModeMismatch`] on a gene-major store and
2298    /// [`ScDataErrors::RawCountOverflow`] for a count that does not fit `T`.
2299    fn read_cells_sparse<T>(
2300        &self,
2301        indices: &[usize],
2302        data_layer: &DataLayerReturn,
2303    ) -> Result<CompressedSparseData2<T, f32>, ScDataErrors>
2304    where
2305        T: BixverseNumeric + FromPrimitive;
2306
2307    /// Read a batch of genes straight into one CSC matrix, optionally keeping
2308    /// only a cell subset.
2309    ///
2310    /// Same output as [`Self::read_gene_parallel_filtered`] (or
2311    /// [`Self::read_gene_parallel`] without a subset) followed by
2312    /// [`from_gene_chunks`], without materialising a chunk per gene. Kept
2313    /// cells are renumbered to their position in `cells_to_keep`.
2314    ///
2315    /// ### Params
2316    ///
2317    /// * `indices` - Gene indices, in output column order
2318    /// * `cells_to_keep` - Cells to keep, in output row order, or `None` for
2319    ///   all cells
2320    /// * `data_layer` - Which layer(s) to materialise
2321    ///
2322    /// ### Returns
2323    ///
2324    /// CSC of shape `(cells, indices.len())`, raw counts in `data` (empty when
2325    /// not requested) and normalised counts in `data_2`, or
2326    /// [`ScDataErrors::ReaderModeMismatch`] on a cell-major store and
2327    /// [`ScDataErrors::RawCountOverflow`] for a count that does not fit `T`.
2328    fn read_genes_sparse<T>(
2329        &self,
2330        indices: &[usize],
2331        cells_to_keep: Option<&IndexSet<u32>>,
2332        data_layer: &DataLayerReturn,
2333    ) -> Result<CompressedSparseData2<T, f32>, ScDataErrors>
2334    where
2335        T: BixverseNumeric + FromPrimitive;
2336
2337    /// Return the header of the underlying store
2338    ///
2339    /// ### Returns
2340    ///
2341    /// The `SparseDataHeader`.
2342    fn get_header(&self) -> SparseDataHeader;
2343
2344    /// Is the store laid out for fast cell retrieval?
2345    ///
2346    /// ### Returns
2347    ///
2348    /// `true` for a cell-based (CSR) store.
2349    fn is_cell_based(&self) -> bool;
2350
2351    /// Is the store laid out for fast gene retrieval?
2352    ///
2353    /// ### Returns
2354    ///
2355    /// `true` for a gene-based (CSC) store.
2356    fn is_gene_based(&self) -> bool {
2357        !self.is_cell_based()
2358    }
2359
2360    /// Library size the `data_norm` layer of this store was scaled to.
2361    ///
2362    /// Needed by anything that has to undo the `ln1p(count / lib_size *
2363    /// target_size)` normalisation, such as `CscGeneChunk::transform_to_clr`.
2364    /// Defaults to `None` for stores that do not record it.
2365    ///
2366    /// ### Returns
2367    ///
2368    /// `Some(target_size)`, or `None` when the store cannot report one.
2369    fn target_size(&self) -> Option<f32> {
2370        None
2371    }
2372
2373    /// Read a single cell by index
2374    ///
2375    /// ### Params
2376    ///
2377    /// * `index` - Cell index
2378    ///
2379    /// ### Returns
2380    ///
2381    /// The `CsrCellChunk` of this cell.
2382    fn read_cell(&self, index: usize) -> Result<CsrCellChunk, ScDataErrors> {
2383        self.read_cells_parallel(&[index])?
2384            .into_iter()
2385            .next()
2386            .ok_or(ScDataErrors::ChunkIndexNotFound(index))
2387    }
2388
2389    /// Read a single gene by index
2390    ///
2391    /// ### Params
2392    ///
2393    /// * `index` - Gene index
2394    ///
2395    /// ### Returns
2396    ///
2397    /// The `CscGeneChunk` of this gene.
2398    fn read_gene(&self, index: usize) -> Result<CscGeneChunk, ScDataErrors> {
2399        self.read_gene_parallel(&[index])?
2400            .into_iter()
2401            .next()
2402            .ok_or(ScDataErrors::ChunkIndexNotFound(index))
2403    }
2404
2405    /// Read a contiguous range of cells
2406    ///
2407    /// Helper for memory-bounded gene generation.
2408    ///
2409    /// ### Params
2410    ///
2411    /// * `start` - First cell index (inclusive)
2412    /// * `end` - Last cell index (exclusive)
2413    ///
2414    /// ### Returns
2415    ///
2416    /// The `CsrCellChunk`s in that range.
2417    fn read_cells_range(
2418        &self,
2419        start: usize,
2420        end: usize,
2421    ) -> Result<Vec<CsrCellChunk>, ScDataErrors> {
2422        let indices: Vec<usize> = (start..end).collect();
2423        self.read_cells_parallel(&indices)
2424    }
2425
2426    /// Return all cells in the store
2427    ///
2428    /// ### Returns
2429    ///
2430    /// Every `CsrCellChunk`.
2431    fn get_all_cells(&self) -> Result<Vec<CsrCellChunk>, ScDataErrors> {
2432        let indices: Vec<usize> = (0..self.get_header().total_cells).collect();
2433        self.read_cells_parallel(&indices)
2434    }
2435
2436    /// Return all genes in the store
2437    ///
2438    /// ### Returns
2439    ///
2440    /// Every `CscGeneChunk`.
2441    fn get_all_genes(&self) -> Result<Vec<CscGeneChunk>, ScDataErrors> {
2442        let indices: Vec<usize> = (0..self.get_header().total_genes).collect();
2443        self.read_gene_parallel(&indices)
2444    }
2445
2446    /// Read genes by index and filter them to a set of cells
2447    ///
2448    /// ### Params
2449    ///
2450    /// * `indices` - Index positions of the genes to retrieve
2451    /// * `cells_to_keep` - Cell index positions to keep, in output order
2452    ///
2453    /// ### Returns
2454    ///
2455    /// The filtered `CscGeneChunk`s.
2456    fn read_gene_parallel_filtered(
2457        &self,
2458        indices: &[usize],
2459        cells_to_keep: &IndexSet<u32>,
2460    ) -> Result<Vec<CscGeneChunk>, ScDataErrors> {
2461        let mut chunks = self.read_gene_parallel(indices)?;
2462        let cell_map = keep_set_index_map(cells_to_keep);
2463        chunks
2464            .par_iter_mut()
2465            .for_each(|chunk| chunk.filter_selected_cells_dense(&cell_map));
2466        Ok(chunks)
2467    }
2468
2469    /// Read only the library sizes for the given cells
2470    ///
2471    /// ### Params
2472    ///
2473    /// * `indices` - Cell indices
2474    ///
2475    /// ### Returns
2476    ///
2477    /// Vector of library sizes in the order given by `indices`.
2478    fn read_cell_library_sizes(&self, indices: &[usize]) -> Result<Vec<usize>, ScDataErrors> {
2479        Ok(self
2480            .read_cells_parallel(indices)?
2481            .iter()
2482            .map(|chunk| chunk.library_size)
2483            .collect())
2484    }
2485
2486    /// Read the NNZ for the given genes
2487    ///
2488    /// ### Params
2489    ///
2490    /// * `indices` - Gene indices
2491    ///
2492    /// ### Returns
2493    ///
2494    /// Vector of NNZ counts in the order given by `indices`.
2495    fn read_gene_nnz(&self, indices: &[usize]) -> Result<Vec<usize>, ScDataErrors> {
2496        Ok(self
2497            .read_gene_parallel(indices)?
2498            .iter()
2499            .map(|chunk| chunk.nnz)
2500            .collect())
2501    }
2502
2503    /// Return NNZ for all genes
2504    ///
2505    /// ### Returns
2506    ///
2507    /// Vector of NNZ values for all genes.
2508    fn get_all_gene_nnz(&self) -> Result<Vec<usize>, ScDataErrors> {
2509        let indices: Vec<usize> = (0..self.get_header().total_genes).collect();
2510        self.read_gene_nnz(&indices)
2511    }
2512
2513    /// Compute the shifted CLR per-cell offsets freshly from raw counts.
2514    ///
2515    /// The offset for cell i is `m_i = (1/D) * sum_j log1p(x_ij / s_i)`, where
2516    /// D is the total gene count and s_i is the cell's library size. Size
2517    /// factor is fixed at 1, independent of any size factor used during the
2518    /// original normalisation. Cells are read in batches to bound peak memory.
2519    ///
2520    /// ### Params
2521    ///
2522    /// * `indices` - Cell indices to compute offsets for
2523    /// * `batch_size` - Number of cells to load per batch (defaults to
2524    ///   [`CELL_BATCH_SIZE`])
2525    ///
2526    /// ### Returns
2527    ///
2528    /// Vector of CLR offsets in the same order as `indices`.
2529    fn get_clr_offsets(
2530        &self,
2531        indices: &[usize],
2532        batch_size: Option<usize>,
2533    ) -> Result<Vec<f64>, ScDataErrors> {
2534        if !self.is_cell_based() {
2535            return Err(ScDataErrors::ReaderModeMismatch {
2536                actual: "gene-based",
2537                requested: "cell-based",
2538            });
2539        }
2540        let batch_size = batch_size.unwrap_or(CELL_BATCH_SIZE);
2541
2542        let n_genes = self.get_header().total_genes as f64;
2543        let mut offsets = Vec::with_capacity(indices.len());
2544
2545        for batch in indices.chunks(batch_size) {
2546            let cells = self.read_cells_parallel(batch)?;
2547            let batch_offsets: Vec<f64> = cells
2548                .par_iter()
2549                .map(|chunk| {
2550                    let inv_s = 1.0_f64 / chunk.library_size as f64;
2551                    let sum: f64 = chunk
2552                        .data_raw
2553                        .iter()
2554                        .map(|c| (c as f64 * inv_s).ln_1p())
2555                        .sum();
2556                    sum / n_genes
2557                })
2558                .collect();
2559            offsets.extend_from_slice(&batch_offsets);
2560        }
2561
2562        Ok(offsets)
2563    }
2564}
2565
2566//////////////////////
2567// Streaming reader //
2568//////////////////////
2569
2570/// Read the `target_size` out of a bin file without mapping the whole file.
2571///
2572/// Only the fixed 64-byte file header is touched, so this is cheap enough to
2573/// run over every input of a merge before deciding what to write.
2574///
2575/// ### Params
2576///
2577/// * `f_path` - Path to the .bin file.
2578///
2579/// ### Returns
2580///
2581/// `Some(target_size)`, or `None` for raw-only files and files written before
2582/// the field entered the header.
2583pub fn peek_target_size<P: AsRef<Path>>(f_path: P) -> Result<Option<f32>, ScDataErrors> {
2584    use std::io::Read;
2585
2586    let mut buffer = [0u8; 64];
2587    File::open(f_path)?.read_exact(&mut buffer)?;
2588
2589    let (file_header, _) = decode_from_slice::<FileHeader, _>(&buffer, config::standard())
2590        .map_err(|_| ScDataErrors::HeaderDecodeFailed)?;
2591
2592    Ok((file_header.target_size != 0.0).then_some(file_header.target_size))
2593}
2594
2595/// Read the format version out of a bin file without mapping the whole file.
2596///
2597/// Only the fixed 64-byte file header is touched, so callers can check a store
2598/// of any size before opening it, and decide whether it needs
2599/// `upgrade_store_to_v4`.
2600///
2601/// ### Params
2602///
2603/// * `f_path` - Path to the .bin file.
2604///
2605/// ### Returns
2606///
2607/// The version, [`SC_FILE_VERSION`] or [`SC_FILE_VERSION_V3`] for files this
2608/// build reads, or [`ScDataErrors::HeaderDecodeFailed`] for a file that is not
2609/// a bixverse store.
2610pub fn peek_file_version<P: AsRef<Path>>(f_path: P) -> Result<u32, ScDataErrors> {
2611    use std::io::Read;
2612
2613    let mut buffer = [0u8; 64];
2614    File::open(f_path)?.read_exact(&mut buffer)?;
2615
2616    let (file_header, _) = decode_from_slice::<FileHeader, _>(&buffer, config::standard())
2617        .map_err(|_| ScDataErrors::HeaderDecodeFailed)?;
2618    if &file_header.magic != FILE_MAGIC {
2619        return Err(ScDataErrors::HeaderDecodeFailed);
2620    }
2621
2622    Ok(file_header.version)
2623}
2624
2625/// ParallelSparseReader
2626pub struct ParallelSparseReader {
2627    /// The file header
2628    header: SparseDataHeader,
2629    /// Reference to the memory map for safe sharing across threads
2630    mmap: Arc<memmap2::Mmap>,
2631    /// The open file, for the explicit reads of [`Self::prefetch`].
2632    file: File,
2633    /// Start position of the chunks after the header file
2634    chunks_start: u64,
2635    /// Library size the `data_norm` layer was normalised against, as recorded
2636    /// in the [`FileHeader`]. `0.0` for files written before the field existed.
2637    target_size: f32,
2638    /// Byte offset in the mmap of the first library-size index entry, or
2639    /// `None` for files written without the index.
2640    library_index: Option<usize>,
2641    /// Whether `index_map` is the identity, letting every lookup skip the hash.
2642    identity_index_map: bool,
2643    /// Format version of the file, [`SC_FILE_VERSION_V3`] or [`SC_FILE_VERSION`].
2644    version: u32,
2645    /// Library size per original cell index as `f32`, on v4 gene-based files
2646    /// that carry the table. Gene chunks recompute their norm from it.
2647    cell_lib: Option<Arc<Vec<f32>>>,
2648    /// Normalised value of small counts per cell, built from `cell_lib` when
2649    /// the file has a target size. See [`gene_norm_table`].
2650    cell_norm: Option<Arc<Vec<u16>>>,
2651}
2652
2653impl ParallelSparseReader {
2654    /// Generate a new parallelised streaming reader
2655    ///
2656    /// ### Params
2657    ///
2658    /// * `f_path` - Path to the file
2659    ///
2660    /// ### Returns
2661    ///
2662    /// Initialised `ParallelSparseReader`.
2663    pub fn new(f_path: &str) -> Result<Self, ScDataErrors> {
2664        let file = File::open(f_path)?;
2665        let file_size = file.metadata()?.len();
2666
2667        let mmap = unsafe {
2668            let mut opts = MmapOptions::new();
2669            if file_size <= 8 * 1024 * 1024 * 1024 {
2670                opts.populate();
2671            }
2672            opts.map(&file)?
2673        };
2674
2675        #[cfg(unix)]
2676        mmap.advise(memmap2::Advice::Random)?;
2677
2678        // Everything below indexes the mmap directly, so each offset is checked
2679        // before it is used. A corrupt or foreign file must return an error,
2680        // not panic on a slice bound or parse as if it were ours.
2681        if mmap.len() < 64 {
2682            return Err(ScDataErrors::HeaderDecodeFailed);
2683        }
2684
2685        let file_header_bytes = &mmap[0..64];
2686        let (file_header, _) =
2687            decode_from_slice::<FileHeader, _>(file_header_bytes, config::standard())
2688                .map_err(|_| ScDataErrors::HeaderDecodeFailed)?;
2689
2690        if &file_header.magic != FILE_MAGIC {
2691            return Err(ScDataErrors::HeaderDecodeFailed);
2692        }
2693
2694        if file_header.version != SC_FILE_VERSION_V3 && file_header.version != SC_FILE_VERSION {
2695            return Err(ScDataErrors::FileVersionMismatch {
2696                expected: SC_FILE_VERSION,
2697                found: file_header.version,
2698            });
2699        }
2700
2701        let main_header_offset = file_header.main_header_offset as usize;
2702        if main_header_offset.saturating_add(8) > mmap.len() {
2703            return Err(ScDataErrors::HeaderDecodeFailed);
2704        }
2705
2706        let header_size = u64::from_le_bytes(
2707            mmap[main_header_offset..main_header_offset + 8]
2708                .try_into()
2709                .expect("8-byte slice, bounds checked above"),
2710        ) as usize;
2711
2712        let header_end = main_header_offset
2713            .saturating_add(8)
2714            .saturating_add(header_size);
2715        if header_end > mmap.len() {
2716            return Err(ScDataErrors::HeaderDecodeFailed);
2717        }
2718
2719        let header_bytes = &mmap[main_header_offset + 8..header_end];
2720        let (header, _) =
2721            decode_from_slice::<SparseDataHeader, _>(header_bytes, config::standard())
2722                .map_err(|_| ScDataErrors::HeaderDecodeFailed)?;
2723
2724        let is_v4_gene = file_header.version == SC_FILE_VERSION && !header.cell_based;
2725        let library_index = if is_v4_gene {
2726            None
2727        } else {
2728            Self::check_library_index(&mmap, file_header.library_index_offset, &header)?
2729        };
2730        let cell_lib = if is_v4_gene {
2731            Self::load_cell_library_table(&mmap, file_header.library_index_offset)?
2732        } else {
2733            None
2734        };
2735        let identity_index_map = header.index_map.len() == header.chunk_offsets.len()
2736            && header
2737                .index_map
2738                .iter()
2739                .all(|(&original, &chunk)| original == chunk);
2740
2741        Ok(Self {
2742            header,
2743            mmap: Arc::new(mmap),
2744            file,
2745            chunks_start: 64,
2746            target_size: file_header.target_size,
2747            library_index,
2748            identity_index_map,
2749            version: file_header.version,
2750            cell_norm: cell_lib
2751                .as_ref()
2752                .filter(|_| file_header.target_size != 0.0)
2753                .map(|lib| Arc::new(gene_norm_table(lib, file_header.target_size))),
2754            cell_lib,
2755        })
2756    }
2757
2758    /// Load the per-cell library-size table of a v4 gene-based file.
2759    ///
2760    /// ### Params
2761    ///
2762    /// * `mmap` - The mapped file.
2763    /// * `offset` - `library_index_offset` from the file header; `0` means the
2764    ///   file has no table.
2765    ///
2766    /// ### Returns
2767    ///
2768    /// The library sizes as `f32`, `None` without a table, or
2769    /// [`ScDataErrors::HeaderDecodeFailed`] if the table is malformed.
2770    fn load_cell_library_table(
2771        mmap: &memmap2::Mmap,
2772        offset: u64,
2773    ) -> Result<Option<Arc<Vec<f32>>>, ScDataErrors> {
2774        if offset == 0 {
2775            return Ok(None);
2776        }
2777        let offset = offset as usize;
2778        if offset.saturating_add(8) > mmap.len() {
2779            return Err(ScDataErrors::HeaderDecodeFailed);
2780        }
2781        let count = le_u64_at(mmap, offset) as usize;
2782        let end = count
2783            .checked_mul(8)
2784            .and_then(|bytes| bytes.checked_add(offset + 8));
2785        if end.is_none_or(|end| end > mmap.len()) {
2786            return Err(ScDataErrors::HeaderDecodeFailed);
2787        }
2788        Ok(Some(Arc::new(
2789            (0..count)
2790                .map(|i| le_u64_at(mmap, offset + 8 + 8 * i) as f32)
2791                .collect(),
2792        )))
2793    }
2794
2795    /// Format version of the file this reader maps.
2796    ///
2797    /// ### Returns
2798    ///
2799    /// [`SC_FILE_VERSION_V3`] or [`SC_FILE_VERSION`].
2800    pub fn file_version(&self) -> u32 {
2801        self.version
2802    }
2803
2804    /// Whether the mapped file is v4.
2805    ///
2806    /// ### Returns
2807    ///
2808    /// `true` for a v4 file.
2809    fn is_v4(&self) -> bool {
2810        self.version == SC_FILE_VERSION
2811    }
2812
2813    /// Decode context for v4 chunks of this file.
2814    ///
2815    /// ### Returns
2816    ///
2817    /// The context.
2818    fn v4_ctx(&self) -> V4Ctx<'_> {
2819        V4Ctx {
2820            target_size: self.target_size,
2821            cell_lib: self.cell_lib.as_deref().map(Vec::as_slice),
2822            cell_norm: self.cell_norm.as_deref().map(Vec::as_slice),
2823            is_cell: self.header.cell_based,
2824        }
2825    }
2826
2827    /// Validate the library-size index a file header points at.
2828    ///
2829    /// The index is a `u64` count followed by one `u64` per chunk. Like the
2830    /// main header, every bound is checked against the mmap, so a corrupt
2831    /// offset or count errors instead of panicking later in a read.
2832    ///
2833    /// ### Params
2834    ///
2835    /// * `mmap` - The mapped file.
2836    /// * `offset` - `library_index_offset` from the file header; `0` means the
2837    ///   file has no index.
2838    /// * `header` - The decoded main header.
2839    ///
2840    /// ### Returns
2841    ///
2842    /// The byte offset of the first entry, `None` without an index, or
2843    /// [`ScDataErrors::HeaderDecodeFailed`] if the index is malformed.
2844    fn check_library_index(
2845        mmap: &memmap2::Mmap,
2846        offset: u64,
2847        header: &SparseDataHeader,
2848    ) -> Result<Option<usize>, ScDataErrors> {
2849        if offset == 0 {
2850            return Ok(None);
2851        }
2852        let offset = offset as usize;
2853        if !header.cell_based || offset.saturating_add(8) > mmap.len() {
2854            return Err(ScDataErrors::HeaderDecodeFailed);
2855        }
2856        let count = le_u64_at(mmap, offset) as usize;
2857        let end = count
2858            .checked_mul(8)
2859            .and_then(|bytes| bytes.checked_add(offset + 8));
2860        if count != header.no_chunks || end.is_none_or(|end| end > mmap.len()) {
2861            return Err(ScDataErrors::HeaderDecodeFailed);
2862        }
2863        Ok(Some(offset + 8))
2864    }
2865
2866    /// Library sizes from the index: 8 bytes per cell, nothing decompressed.
2867    ///
2868    /// ### Params
2869    ///
2870    /// * `start` - Offset of the first index entry (validated on open).
2871    /// * `indices` - Original cell indices.
2872    ///
2873    /// ### Returns
2874    ///
2875    /// Library sizes in the order of `indices`.
2876    fn library_sizes_from_index(
2877        &self,
2878        start: usize,
2879        indices: &[usize],
2880    ) -> Result<Vec<usize>, ScDataErrors> {
2881        indices
2882            .iter()
2883            .map(|&original_index| {
2884                let chunk = self
2885                    .header
2886                    .index_map
2887                    .get(&original_index)
2888                    .copied()
2889                    .filter(|&chunk| chunk < self.header.no_chunks)
2890                    .ok_or(ScDataErrors::ChunkIndexNotFound(original_index))?;
2891                Ok(le_u64_at(&self.mmap, start + 8 * chunk) as usize)
2892            })
2893            .collect()
2894    }
2895
2896    /// Whether the file carries a library-size index. Lets tests in other
2897    /// modules check which path `read_cell_library_sizes` takes.
2898    #[cfg(test)]
2899    pub(crate) fn has_library_index(&self) -> bool {
2900        self.library_index.is_some()
2901    }
2902
2903    /// Resolve an original cell or gene index to its chunk ordinal.
2904    ///
2905    /// A store written in one pass has `index_map[i] == i`, which is the
2906    /// overwhelmingly common case, so that is checked first and skips the hash
2907    /// lookup entirely. The map is only consulted for stores whose chunks were
2908    /// written out of order.
2909    ///
2910    /// ### Params
2911    ///
2912    /// * `original_index` - Original index of the cell or gene in the data
2913    ///
2914    /// ### Returns
2915    ///
2916    /// The chunk ordinal, or [`ScDataErrors::ChunkIndexNotFound`].
2917    fn chunk_index_of(&self, original_index: usize) -> Result<usize, ScDataErrors> {
2918        if self.identity_index_map {
2919            return if original_index < self.header.chunk_offsets.len() {
2920                Ok(original_index)
2921            } else {
2922                Err(ScDataErrors::ChunkIndexNotFound(original_index))
2923            };
2924        }
2925
2926        self.header
2927            .index_map
2928            .get(&original_index)
2929            .copied()
2930            .ok_or(ScDataErrors::ChunkIndexNotFound(original_index))
2931    }
2932
2933    /// Count the chunks that store their normalised layer verbatim.
2934    ///
2935    /// Only reads the fixed chunk headers. Such chunks skip the norm recompute
2936    /// on read and are larger on disk.
2937    ///
2938    /// ### Returns
2939    ///
2940    /// The number of norm-stored chunks, or
2941    /// [`ScDataErrors::FileVersionMismatch`] on a v3 file, which always stores
2942    /// the layer.
2943    pub fn count_norm_stored_chunks(&self) -> Result<usize, ScDataErrors> {
2944        if !self.is_v4() {
2945            return Err(ScDataErrors::FileVersionMismatch {
2946                expected: SC_FILE_VERSION,
2947                found: self.version,
2948            });
2949        }
2950        let offsets = &self.header.chunk_offsets;
2951        (0..offsets.len())
2952            .into_par_iter()
2953            .map(|c| {
2954                // chunk layout: u64 compressed length, then the v4 header
2955                let at = (self.chunks_start + offsets[c]) as usize + 8;
2956                Ok(V4Header::parse(&self.mmap[at..])?.norm_stored() as usize)
2957            })
2958            .sum()
2959    }
2960
2961    /// Decode the cells `start..end` of a v4 cell file in order, without the
2962    /// norm layer, handing each to `f`.
2963    ///
2964    /// Sequential and allocation free after the first cell: meant for callers
2965    /// that already fan out over cell ranges. Indices are always decoded, raw
2966    /// counts only with `want_raw`.
2967    ///
2968    /// ### Params
2969    ///
2970    /// * `start` - First cell (inclusive)
2971    /// * `end` - Last cell (exclusive)
2972    /// * `want_raw` - Decode raw counts; otherwise `f` gets an empty slice
2973    /// * `f` - Called as `f(cell, indices, raw)`
2974    ///
2975    /// ### Returns
2976    ///
2977    /// `Ok(())`, a mode or version mismatch, or the first decode error.
2978    pub(crate) fn visit_cells_v4(
2979        &self,
2980        start: usize,
2981        end: usize,
2982        want_raw: bool,
2983        mut f: impl FnMut(usize, &[u32], &[u32]),
2984    ) -> Result<(), ScDataErrors> {
2985        if !self.is_cell_based() {
2986            return Err(ScDataErrors::ReaderModeMismatch {
2987                actual: "gene-based",
2988                requested: "cell-based",
2989            });
2990        }
2991        if !self.is_v4() {
2992            return Err(ScDataErrors::FileVersionMismatch {
2993                expected: SC_FILE_VERSION,
2994                found: self.version,
2995            });
2996        }
2997
2998        self.prefetch(&(start..end).collect::<Vec<_>>());
2999        let ctx = self.v4_ctx();
3000        let mut s = V4Decoded::default();
3001        for cell in start..end {
3002            let (chunk, _) = self.locate_chunk(cell)?;
3003            decode_v4(chunk, &ctx, want_raw, false, &mut s)?;
3004            let raw = if want_raw { &s.raw[..s.idx.len()] } else { &[] };
3005            f(cell, &s.idx, raw);
3006        }
3007        Ok(())
3008    }
3009
3010    /// Locate the compressed bytes of a single chunk in the mapping.
3011    ///
3012    /// ### Params
3013    ///
3014    /// * `original_index` - Original index of the cell or gene in the data
3015    ///
3016    /// ### Returns
3017    ///
3018    /// The compressed slice, still carrying lz4's own size prefix, and the
3019    /// chunk's byte offset for error reporting.
3020    fn locate_chunk(&self, original_index: usize) -> Result<(&[u8], usize), ScDataErrors> {
3021        let chunk_index = self.chunk_index_of(original_index)?;
3022        let chunk_offset = (self.chunks_start + self.header.chunk_offsets[chunk_index]) as usize;
3023
3024        let compressed_size = u64::from_le_bytes(
3025            self.mmap[chunk_offset..chunk_offset + 8]
3026                .try_into()
3027                .expect("8-byte slice by construction"),
3028        ) as usize;
3029
3030        Ok((
3031            &self.mmap[chunk_offset + 8..chunk_offset + 8 + compressed_size],
3032            chunk_offset,
3033        ))
3034    }
3035
3036    /// Locate, decompress and return the raw bytes of a single chunk
3037    ///
3038    /// Shared by every read path: resolve the original index to a chunk offset
3039    /// via the header index map, read the length prefix, then lz4 decompress.
3040    ///
3041    /// ### Params
3042    ///
3043    /// * `original_index` - Original index of the cell or gene in the data
3044    ///
3045    /// ### Returns
3046    ///
3047    /// The decompressed chunk bytes.
3048    fn decompress_chunk(&self, original_index: usize) -> Result<Vec<u8>, ScDataErrors> {
3049        let (compressed, chunk_offset) = self.locate_chunk(original_index)?;
3050
3051        decompress_size_prepended(compressed)
3052            .map_err(|_| ScDataErrors::ChunkDecompressionFailed(chunk_offset as u64))
3053    }
3054
3055    /// Decompress a chunk into a caller-owned buffer.
3056    ///
3057    /// The allocating [`Self::decompress_chunk`] costs one `Vec` plus one
3058    /// zero-fill per chunk, which for a shuffled minibatch is a fresh
3059    /// allocation per cell. Handing in a scratch buffer that is reused across
3060    /// the cells a worker owns removes both: `resize` only zeroes on growth,
3061    /// and after the first few cells it stops growing.
3062    ///
3063    /// ### Params
3064    ///
3065    /// * `original_index` - Original index of the cell or gene in the data
3066    /// * `scratch` - Buffer to decompress into. Resized to the payload.
3067    ///
3068    /// ### Returns
3069    ///
3070    /// `Ok(())` with `scratch` holding exactly the decompressed chunk.
3071    fn decompress_chunk_into(
3072        &self,
3073        original_index: usize,
3074        scratch: &mut Vec<u8>,
3075    ) -> Result<(), ScDataErrors> {
3076        let (compressed, chunk_offset) = self.locate_chunk(original_index)?;
3077
3078        let (size, payload) = uncompressed_size(compressed)
3079            .map_err(|_| ScDataErrors::ChunkDecompressionFailed(chunk_offset as u64))?;
3080
3081        scratch.resize(size, 0);
3082        let written = decompress_into(payload, scratch)
3083            .map_err(|_| ScDataErrors::ChunkDecompressionFailed(chunk_offset as u64))?;
3084        scratch.truncate(written);
3085
3086        Ok(())
3087    }
3088}
3089
3090//////////////
3091// Prefetch //
3092//////////////
3093
3094/// Bytes per explicit read in [`ParallelSparseReader::prefetch`].
3095///
3096/// Large reads keep the SSD at a useful queue depth with few syscalls, which
3097/// page faults on a memory map do not: they arrive one page at a time per
3098/// thread. Not tuned beyond being large.
3099#[cfg(unix)]
3100const PREFETCH_BLOCK: usize = 8 * 1024 * 1024;
3101
3102/// Smallest total span worth prefetching. Below it the faults of the decode
3103/// itself are cheaper than setting the reads up. One byte under test, so the
3104/// unit tests' small stores exercise the prefetch path.
3105#[cfg(unix)]
3106const PREFETCH_MIN_BYTES: usize = if cfg!(test) { 1 } else { 16 * 1024 * 1024 };
3107
3108/// Two chunk spans closer than this are read as one range, so a sparse index
3109/// set does not turn into many tiny reads.
3110#[cfg(unix)]
3111const PREFETCH_MERGE_GAP: usize = 256 * 1024;
3112
3113/// Ranges probed for residency before deciding whether to prefetch at all.
3114#[cfg(unix)]
3115const PREFETCH_SAMPLES: usize = 32;
3116
3117/// A block with at least this fraction of its pages already cached is skipped,
3118/// so prefetching a warm store costs a residency probe instead of a copy.
3119#[cfg(unix)]
3120const PREFETCH_SKIP_RESIDENT: f64 = 0.9;
3121
3122impl ParallelSparseReader {
3123    /// Pull the chunks about to be read into the page cache.
3124    ///
3125    /// Best effort: an I/O error or an out-of-range index is ignored here and
3126    /// surfaces in the read that follows. Chunk spans are merged, cut into
3127    /// [`PREFETCH_BLOCK`] reads and issued in parallel, skipping blocks that
3128    /// are already resident. The pages then serve the memory map the decode
3129    /// reads from.
3130    ///
3131    /// ### Params
3132    ///
3133    /// * `indices` - Original indices of the cells or genes about to be read
3134    #[cfg(unix)]
3135    fn prefetch(&self, indices: &[usize]) {
3136        use std::os::unix::fs::FileExt;
3137
3138        let offsets = &self.header.chunk_offsets;
3139        let mut spans: Vec<(usize, usize)> = indices
3140            .iter()
3141            .filter_map(|&i| {
3142                let chunk = self.chunk_index_of(i).ok()?;
3143                let start = (self.chunks_start + offsets[chunk]) as usize;
3144                let end = offsets
3145                    .get(chunk + 1)
3146                    .map_or(start + 8, |&next| (self.chunks_start + next) as usize);
3147                Some((start, end))
3148            })
3149            .collect();
3150        if spans.iter().map(|&(s, e)| e - s).sum::<usize>() < PREFETCH_MIN_BYTES {
3151            return;
3152        }
3153
3154        spans.sort_unstable();
3155        let mut ranges: Vec<(usize, usize)> = Vec::new();
3156        for (start, end) in spans {
3157            match ranges.last_mut() {
3158                Some(last) if start <= last.1 + PREFETCH_MERGE_GAP => last.1 = last.1.max(end),
3159                _ => ranges.push((start, end)),
3160            }
3161        }
3162
3163        // SAFETY: `sysconf` with a valid name has no preconditions.
3164        let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }.max(4096) as usize;
3165
3166        // A handful of probes decide whether any of this is needed, so a warm
3167        // store pays almost nothing.
3168        let step = (ranges.len() / PREFETCH_SAMPLES).max(1);
3169        let probed: Vec<f64> = ranges
3170            .iter()
3171            .step_by(step)
3172            .filter(|&&(s, e)| s / page * page < self.mmap.len() && e > s)
3173            .map(|&(s, e)| {
3174                let s = s / page * page;
3175                let e = e.min(self.mmap.len());
3176                self.resident_fraction(s, e - s, page)
3177            })
3178            .collect();
3179        if probed.is_empty()
3180            || probed.iter().sum::<f64>() / probed.len() as f64 >= PREFETCH_SKIP_RESIDENT
3181        {
3182            return;
3183        }
3184        let blocks: Vec<(usize, usize)> = ranges
3185            .iter()
3186            .flat_map(|&(start, end)| {
3187                let start = start / page * page;
3188                (start..end)
3189                    .step_by(PREFETCH_BLOCK)
3190                    .map(move |s| (s, (s + PREFETCH_BLOCK).min(end)))
3191            })
3192            .filter(|&(s, e)| s < self.mmap.len() && e > s)
3193            .collect();
3194
3195        blocks.par_iter().for_each_init(
3196            || vec![0u8; PREFETCH_BLOCK],
3197            |buf, &(start, end)| {
3198                let end = end.min(self.mmap.len());
3199                if self.resident_fraction(start, end - start, page) >= PREFETCH_SKIP_RESIDENT {
3200                    return;
3201                }
3202                let mut done = 0;
3203                while done < end - start {
3204                    match self
3205                        .file
3206                        .read_at(&mut buf[..end - start - done], (start + done) as u64)
3207                    {
3208                        Ok(0) | Err(_) => break,
3209                        Ok(n) => done += n,
3210                    }
3211                }
3212            },
3213        );
3214    }
3215
3216    /// Fraction of the pages of a byte range that sit in the page cache.
3217    ///
3218    /// ### Params
3219    ///
3220    /// * `start` - Page-aligned byte offset into the file
3221    /// * `len` - Length in bytes
3222    /// * `page` - System page size
3223    ///
3224    /// ### Returns
3225    ///
3226    /// The resident fraction, `0.0` if the probe fails.
3227    #[cfg(unix)]
3228    fn resident_fraction(&self, start: usize, len: usize, page: usize) -> f64 {
3229        let mut vec = vec![0u8; len.div_ceil(page)];
3230        // SAFETY: `start..start + len` lies inside the mapping, `start` is
3231        // page aligned, and `vec` holds one byte per page as `mincore` needs.
3232        let rc = unsafe {
3233            libc::mincore(
3234                self.mmap.as_ptr().add(start) as *mut libc::c_void,
3235                len,
3236                vec.as_mut_ptr().cast(),
3237            )
3238        };
3239        if rc != 0 {
3240            return 0.0;
3241        }
3242        vec.iter().filter(|&&b| b & 1 != 0).count() as f64 / vec.len().max(1) as f64
3243    }
3244
3245    /// No-op where explicit prefetching is not implemented.
3246    ///
3247    /// ### Params
3248    ///
3249    /// * `_indices` - Unused
3250    #[cfg(not(unix))]
3251    fn prefetch(&self, _indices: &[usize]) {}
3252}
3253
3254///////////////////////
3255// Fused batch reads //
3256///////////////////////
3257
3258/// Pieces per rayon thread in [`read_fused`].
3259///
3260/// One piece per thread maximises scratch reuse but leaves no work to steal,
3261/// so the slowest thread (an efficiency core, or the piece holding the
3262/// heaviest genes) sets the wall time. Several pieces per thread restore
3263/// stealing at the cost of a few more scratch buffers.
3264const FUSED_PIECES_PER_THREAD: usize = 8;
3265
3266/// Convert one raw count exactly into `T`.
3267///
3268/// ### Params
3269///
3270/// * `val` - The stored count
3271///
3272/// ### Returns
3273///
3274/// The count as `T`, or [`ScDataErrors::RawCountOverflow`] when it does not
3275/// fit.
3276#[inline(always)]
3277fn raw_to<T: FromPrimitive>(val: u32) -> Result<T, ScDataErrors> {
3278    T::from_u32(val).ok_or(ScDataErrors::RawCountOverflow {
3279        value: val,
3280        target_type: std::any::type_name::<T>(),
3281    })
3282}
3283
3284/// One worker's share of a fused batch read, before the fragments are
3285/// concatenated.
3286///
3287/// Runs are CSR rows (cell reads) or CSC columns (gene reads). The layers that
3288/// were not requested stay `None` and cost nothing.
3289pub(crate) struct SparseFragment<T> {
3290    /// Raw counts, when requested.
3291    data: Option<Vec<T>>,
3292    /// Normalised counts, when requested.
3293    data_2: Option<Vec<f32>>,
3294    /// Minor-axis indices, renumbered when a row map is in play.
3295    indices: Vec<u32>,
3296    /// Entries per run, in order, for the prefix sum.
3297    lengths: Vec<usize>,
3298    /// Reused `(output row, entry position)` scratch for filtered runs.
3299    kept: Vec<(u32, u32)>,
3300}
3301
3302impl<T: BixverseNumeric + FromPrimitive> SparseFragment<T> {
3303    /// Empty fragment.
3304    ///
3305    /// ### Params
3306    ///
3307    /// * `layers` - `(raw, norm)`: which layers to materialise
3308    /// * `n_runs` - Runs this worker will append
3309    ///
3310    /// ### Returns
3311    ///
3312    /// The fragment.
3313    fn new(layers: (bool, bool), n_runs: usize) -> Self {
3314        Self {
3315            data: layers.0.then(Vec::new),
3316            data_2: layers.1.then(Vec::new),
3317            indices: Vec::new(),
3318            lengths: Vec::with_capacity(n_runs),
3319            kept: Vec::new(),
3320        }
3321    }
3322
3323    /// Append one v4 run.
3324    ///
3325    /// Without a `row_map` the chunk is decoded straight into this fragment's
3326    /// vectors, with no intermediate copy. With one, the run is decoded into
3327    /// `scratch` and filtered through [`Self::push_run`].
3328    ///
3329    /// ### Params
3330    ///
3331    /// * `chunk` - The v4 chunk, header first
3332    /// * `ctx` - File context
3333    /// * `scratch` - The worker's reusable buffers
3334    /// * `row_map` - Optional dense `index -> output row` map
3335    ///
3336    /// ### Returns
3337    ///
3338    /// `Ok(())`, or the first decode or [`ScDataErrors::RawCountOverflow`]
3339    /// error.
3340    pub(crate) fn push_v4(
3341        &mut self,
3342        chunk: &[u8],
3343        ctx: &V4Ctx,
3344        scratch: &mut V4Decoded,
3345        row_map: Option<&[u32]>,
3346    ) -> Result<(), ScDataErrors> {
3347        let (want_raw, want_norm) = self.layers();
3348        if row_map.is_some() {
3349            decode_v4(chunk, ctx, want_raw, want_norm, scratch)?;
3350            return self.push_run(&V4Run::new(scratch), row_map);
3351        }
3352
3353        let h = decode_v4_into(
3354            chunk,
3355            ctx,
3356            want_raw,
3357            &mut scratch.bytes,
3358            &mut scratch.raw,
3359            &mut self.indices,
3360            self.data_2.as_mut(),
3361        )?;
3362        if let Some(data) = self.data.as_mut() {
3363            extend_raw_checked(data, scratch.raw.iter().copied())?;
3364        }
3365        self.lengths.push(h.nnz);
3366        Ok(())
3367    }
3368
3369    /// Which layers this fragment materialises.
3370    ///
3371    /// ### Returns
3372    ///
3373    /// `(raw, norm)`.
3374    pub(crate) fn layers(&self) -> (bool, bool) {
3375        (self.data.is_some(), self.data_2.is_some())
3376    }
3377
3378    /// Append one run.
3379    ///
3380    /// With a `row_map` the run is filtered and renumbered on the way in:
3381    /// entries whose index maps to [`INDEX_DROPPED`] (or past the map) are
3382    /// dropped, and the rest are reordered by output row. Stored indices
3383    /// ascend, so the sort only runs when the map itself is not monotone. This
3384    /// is the fused form of [`CscGeneChunk::filter_selected_cells_dense`] and
3385    /// gives identical output.
3386    ///
3387    /// ### Params
3388    ///
3389    /// * `run` - The stored run
3390    /// * `row_map` - Optional dense `index -> output row` map
3391    ///
3392    /// ### Returns
3393    ///
3394    /// `Ok(())`, or [`ScDataErrors::RawCountOverflow`] for a count that does
3395    /// not fit `T`.
3396    #[inline]
3397    pub(crate) fn push_run(
3398        &mut self,
3399        run: &impl RunSource,
3400        row_map: Option<&[u32]>,
3401    ) -> Result<(), ScDataErrors> {
3402        let Some(map) = row_map else {
3403            if let Some(data) = self.data.as_mut() {
3404                run.extend_raw(data)?;
3405            }
3406            if let Some(data_2) = self.data_2.as_mut() {
3407                run.extend_norm(data_2);
3408            }
3409            run.extend_indices(&mut self.indices);
3410            self.lengths.push(run.nnz());
3411            return Ok(());
3412        };
3413
3414        self.kept.clear();
3415        self.kept.extend(
3416            (0..run.nnz()).filter_map(|i| match map.get(run.index_at(i) as usize) {
3417                Some(&row) if row != INDEX_DROPPED => Some((row, i as u32)),
3418                _ => None,
3419            }),
3420        );
3421        if !self.kept.is_sorted_by_key(|&(row, _)| row) {
3422            self.kept.sort_unstable_by_key(|&(row, _)| row);
3423        }
3424
3425        if let Some(data) = self.data.as_mut() {
3426            for &(_, i) in &self.kept {
3427                data.push(raw_to(run.raw_at(i as usize))?);
3428            }
3429        }
3430        if let Some(data_2) = self.data_2.as_mut() {
3431            data_2.extend(self.kept.iter().map(|&(_, i)| run.norm_at(i as usize)));
3432        }
3433        self.indices.extend(self.kept.iter().map(|&(row, _)| row));
3434        self.lengths.push(self.kept.len());
3435
3436        Ok(())
3437    }
3438}
3439
3440/// Which layers a [`DataLayerReturn`] asks for.
3441///
3442/// ### Params
3443///
3444/// * `data_layer` - The requested layer(s)
3445///
3446/// ### Returns
3447///
3448/// `(raw, norm)`.
3449fn wanted_layers(data_layer: &DataLayerReturn) -> (bool, bool) {
3450    (
3451        matches!(
3452            data_layer,
3453            DataLayerReturn::Raw | DataLayerReturn::BothLayers
3454        ),
3455        matches!(
3456            data_layer,
3457            DataLayerReturn::Norm | DataLayerReturn::BothLayers
3458        ),
3459    )
3460}
3461
3462/// Read a batch of runs straight into one compressed sparse matrix.
3463///
3464/// The engine behind [`SingleCellReading::read_cells_sparse`] and
3465/// [`SingleCellReading::read_genes_sparse`]. Work is split into one contiguous
3466/// piece per rayon worker rather than per run, so each piece reuses one
3467/// scratch buffer (and one [`SparseFragment`]) across all of its runs, and
3468/// allocation scales with the thread count rather than the batch size. The
3469/// pieces are concatenated in order, so the output run order follows
3470/// `indices`.
3471///
3472/// ### Params
3473///
3474/// * `indices` - Runs to read, in output order
3475/// * `data_layer` - Which layer(s) to materialise
3476/// * `cs_type` - Layout of the output: CSR for cell runs, CSC for gene runs
3477/// * `shape` - `(cells, genes)` of the output
3478/// * `fill` - Appends run `indices[i]` to the fragment; the [`V4Decoded`] is
3479///   the worker's reusable scratch
3480///
3481/// ### Returns
3482///
3483/// The batch, raw counts in `data` (empty when not requested) and normalised
3484/// counts in `data_2`.
3485pub(crate) fn read_fused<T, F>(
3486    indices: &[usize],
3487    data_layer: &DataLayerReturn,
3488    cs_type: CompressedSparseFormat,
3489    shape: (usize, usize),
3490    fill: F,
3491) -> Result<CompressedSparseData2<T, f32>, ScDataErrors>
3492where
3493    T: BixverseNumeric + FromPrimitive,
3494    F: Fn(usize, &mut V4Decoded, &mut SparseFragment<T>) -> Result<(), ScDataErrors> + Sync,
3495{
3496    let layers = wanted_layers(data_layer);
3497    let n_pieces = rayon::current_num_threads().max(1) * FUSED_PIECES_PER_THREAD;
3498    let piece = indices.len().div_ceil(n_pieces).max(1);
3499
3500    let fragments: Vec<SparseFragment<T>> = indices
3501        .par_chunks(piece)
3502        .map(|runs| {
3503            let mut scratch = V4Decoded::default();
3504            let mut fragment = SparseFragment::new(layers, runs.len());
3505            for &run in runs {
3506                fill(run, &mut scratch, &mut fragment)?;
3507            }
3508            Ok(fragment)
3509        })
3510        .collect::<Result<Vec<_>, ScDataErrors>>()?;
3511
3512    // Concatenate in parallel: each fragment copies into its own disjoint
3513    // slot of preallocated outputs. A serial append here costs more than the
3514    // whole parallel decode.
3515    let nnz_offsets: Vec<usize> = std::iter::once(0)
3516        .chain(fragments.iter().scan(0, |acc, f| {
3517            *acc += f.indices.len();
3518            Some(*acc)
3519        }))
3520        .collect();
3521    let run_offsets: Vec<usize> = std::iter::once(0)
3522        .chain(fragments.iter().scan(0, |acc, f| {
3523            *acc += f.lengths.len();
3524            Some(*acc)
3525        }))
3526        .collect();
3527    let nnz = *nnz_offsets.last().expect("seeded with 0");
3528
3529    let mut data: Vec<T> = if layers.0 {
3530        vec![T::default(); nnz]
3531    } else {
3532        Vec::new()
3533    };
3534    let mut data_2: Option<Vec<f32>> = layers.1.then(|| vec![0.0; nnz]);
3535    let mut out_indices: Vec<u32> = vec![0; nnz];
3536    let mut indptr: Vec<u32> = vec![0; indices.len() + 1];
3537
3538    let data_parts = split_at_offsets(&mut data, &nnz_offsets, layers.0);
3539    let data_2_parts = split_at_offsets(
3540        data_2.as_deref_mut().unwrap_or_default(),
3541        &nnz_offsets,
3542        layers.1,
3543    );
3544    let index_parts = split_at_offsets(&mut out_indices, &nnz_offsets, true);
3545    let indptr_parts = split_at_offsets(&mut indptr[1..], &run_offsets, true);
3546
3547    fragments
3548        .par_iter()
3549        .zip(data_parts)
3550        .zip(data_2_parts)
3551        .zip(index_parts)
3552        .zip(indptr_parts)
3553        .zip(nnz_offsets.par_iter())
3554        .for_each(|(((((fragment, d), d2), idx), ptr), &base)| {
3555            if let Some(src) = fragment.data.as_ref() {
3556                d.copy_from_slice(src);
3557            }
3558            if let Some(src) = fragment.data_2.as_ref() {
3559                d2.copy_from_slice(src);
3560            }
3561            idx.copy_from_slice(&fragment.indices);
3562            let mut offset = base;
3563            for (out, &len) in ptr.iter_mut().zip(&fragment.lengths) {
3564                offset += len;
3565                *out = offset as u32;
3566            }
3567        });
3568
3569    Ok(CompressedSparseData2 {
3570        data,
3571        indices: out_indices,
3572        indptr,
3573        cs_type,
3574        data_2,
3575        shape,
3576    })
3577}
3578
3579/// Split a slice into consecutive parts at the given offsets.
3580///
3581/// ### Params
3582///
3583/// * `buf` - The slice to split
3584/// * `offsets` - Prefix offsets with a leading 0, one more than the parts
3585/// * `active` - `false` for a layer that was not requested: `buf` is empty and
3586///   every part comes back empty, so the parts still zip one per fragment
3587///
3588/// ### Returns
3589///
3590/// The parts, in order.
3591fn split_at_offsets<'a, X>(buf: &'a mut [X], offsets: &[usize], active: bool) -> Vec<&'a mut [X]> {
3592    let n_parts = offsets.len().saturating_sub(1);
3593    if !active {
3594        return (0..n_parts).map(|_| &mut [][..]).collect();
3595    }
3596    let mut parts = Vec::with_capacity(n_parts);
3597    let mut rest = buf;
3598    for w in offsets.windows(2) {
3599        let (head, tail) = rest.split_at_mut(w[1] - w[0]);
3600        parts.push(head);
3601        rest = tail;
3602    }
3603    parts
3604}
3605
3606/////////////////////
3607// Cell row subset //
3608/////////////////////
3609
3610/// Dense map and output row count for a cell subset.
3611///
3612/// ### Params
3613///
3614/// * `cells_to_keep` - Cells to keep, in output order, or `None` for all
3615/// * `total_cells` - Cells in the store
3616///
3617/// ### Returns
3618///
3619/// `(map, n_rows)`: the map is `None` when no filtering is asked for.
3620pub(crate) fn cell_row_map(
3621    cells_to_keep: Option<&IndexSet<u32>>,
3622    total_cells: usize,
3623) -> (Option<Vec<u32>>, usize) {
3624    match cells_to_keep {
3625        Some(set) => (Some(keep_set_index_map(set)), set.len()),
3626        None => (None, total_cells),
3627    }
3628}
3629
3630//////////////
3631// CsrBatch //
3632//////////////
3633
3634/// A batch of cells as one CSR matrix.
3635///
3636/// The layout a training loop wants: contiguous `data`, `indices` and `indptr`
3637/// that hand straight to `scipy.sparse.csr_matrix` or `torch.sparse_csr_tensor`
3638/// without another pass. Whichever layer was not requested is `None`; both
3639/// share one sparsity pattern, so `indices` and `indptr` are never duplicated.
3640#[derive(Clone, Debug)]
3641pub struct CsrBatch {
3642    /// Raw counts as `f32`, in CSR order, when requested.
3643    pub data_raw: Option<Vec<f32>>,
3644    /// Normalised counts as `f32`, in CSR order, when requested.
3645    pub data_norm: Option<Vec<f32>>,
3646    /// Gene indices, one per non-zero.
3647    pub indices: Vec<u32>,
3648    /// Row pointers, of length `shape.0 + 1`.
3649    pub indptr: Vec<u32>,
3650    /// `(cells in the batch, total genes)`.
3651    pub shape: (usize, usize),
3652}
3653
3654impl ParallelSparseReader {
3655    /// Read a batch of cells straight into CSR, as `f32`.
3656    ///
3657    /// [`SingleCellReading::read_cells_sparse`] with `T = f32`, unpacked into
3658    /// the [`CsrBatch`] the Python loader hands to scipy or torch.
3659    ///
3660    /// ### Params
3661    ///
3662    /// * `indices` - Original cell indices, in the order the batch wants them
3663    /// * `data_layer` - Which layer(s) to materialise
3664    ///
3665    /// ### Returns
3666    ///
3667    /// The batch as CSR, or [`ScDataErrors::ReaderModeMismatch`] on a
3668    /// gene-major store.
3669    pub fn read_cells_csr(
3670        &self,
3671        indices: &[usize],
3672        data_layer: &DataLayerReturn,
3673    ) -> Result<CsrBatch, ScDataErrors> {
3674        let csr = self.read_cells_sparse::<f32>(indices, data_layer)?;
3675
3676        Ok(CsrBatch {
3677            data_raw: wanted_layers(data_layer).0.then_some(csr.data),
3678            data_norm: csr.data_2,
3679            indices: csr.indices,
3680            indptr: csr.indptr,
3681            shape: csr.shape,
3682        })
3683    }
3684}
3685
3686//////////////////////////
3687// Trait implementation //
3688//////////////////////////
3689
3690impl SingleCellReading for ParallelSparseReader {
3691    /// Read in cells by indices in a multi-threaded manner
3692    ///
3693    /// ### Params
3694    ///
3695    /// * `indices` - Slice of index positions of the cells to retrieve
3696    ///
3697    /// ### Returns
3698    ///
3699    /// Returns an array of `CsrCellChunk`.
3700    fn read_cells_parallel(&self, indices: &[usize]) -> Result<Vec<CsrCellChunk>, ScDataErrors> {
3701        if !self.is_cell_based() {
3702            return Err(ScDataErrors::ReaderModeMismatch {
3703                actual: "gene-based",
3704                requested: "cell-based",
3705            });
3706        }
3707
3708        self.prefetch(indices);
3709
3710        if self.is_v4() {
3711            let ctx = self.v4_ctx();
3712            return indices
3713                .par_iter()
3714                .map_init(V4Decoded::default, |scratch, &original_index| {
3715                    let (chunk, _) = self.locate_chunk(original_index)?;
3716                    v4_cell_chunk(chunk, &ctx, scratch)
3717                })
3718                .collect();
3719        }
3720
3721        indices
3722            .par_iter()
3723            .map(|&original_index| {
3724                let decompressed = self.decompress_chunk(original_index)?;
3725
3726                CsrCellChunk::read_from_buffer(&decompressed)
3727            })
3728            .collect()
3729    }
3730
3731    /// Read a batch of cells straight into CSR
3732    ///
3733    /// Decompresses each cell into the worker's scratch buffer and appends
3734    /// its payload in place, see [`read_fused`].
3735    ///
3736    /// ### Params
3737    ///
3738    /// * `indices` - Cell indices, in output row order
3739    /// * `data_layer` - Which layer(s) to materialise
3740    ///
3741    /// ### Returns
3742    ///
3743    /// The batch as CSR.
3744    fn read_cells_sparse<T>(
3745        &self,
3746        indices: &[usize],
3747        data_layer: &DataLayerReturn,
3748    ) -> Result<CompressedSparseData2<T, f32>, ScDataErrors>
3749    where
3750        T: BixverseNumeric + FromPrimitive,
3751    {
3752        if !self.is_cell_based() {
3753            return Err(ScDataErrors::ReaderModeMismatch {
3754                actual: "gene-based",
3755                requested: "cell-based",
3756            });
3757        }
3758
3759        self.prefetch(indices);
3760
3761        read_fused(
3762            indices,
3763            data_layer,
3764            CompressedSparseFormat::Csr,
3765            (indices.len(), self.header.total_genes),
3766            |cell, scratch, fragment| {
3767                if self.is_v4() {
3768                    let (chunk, _) = self.locate_chunk(cell)?;
3769                    return fragment.push_v4(chunk, &self.v4_ctx(), scratch, None);
3770                }
3771                self.decompress_chunk_into(cell, &mut scratch.bytes)?;
3772                let p = csr_payload(&scratch.bytes)?;
3773                fragment.push_run(&p, None)
3774            },
3775        )
3776    }
3777
3778    /// Read a batch of genes straight into CSC, optionally filtering cells
3779    ///
3780    /// Decompresses each gene into the worker's scratch buffer and appends
3781    /// its payload in place, filtering through a dense cell map built once
3782    /// per call, see [`read_fused`].
3783    ///
3784    /// ### Params
3785    ///
3786    /// * `indices` - Gene indices, in output column order
3787    /// * `cells_to_keep` - Cells to keep, in output row order, or `None`
3788    /// * `data_layer` - Which layer(s) to materialise
3789    ///
3790    /// ### Returns
3791    ///
3792    /// The batch as CSC.
3793    fn read_genes_sparse<T>(
3794        &self,
3795        indices: &[usize],
3796        cells_to_keep: Option<&IndexSet<u32>>,
3797        data_layer: &DataLayerReturn,
3798    ) -> Result<CompressedSparseData2<T, f32>, ScDataErrors>
3799    where
3800        T: BixverseNumeric + FromPrimitive,
3801    {
3802        if !self.is_gene_based() {
3803            return Err(ScDataErrors::ReaderModeMismatch {
3804                actual: "cell-based",
3805                requested: "gene-based",
3806            });
3807        }
3808
3809        let (row_map, n_rows) = cell_row_map(cells_to_keep, self.header.total_cells);
3810
3811        self.prefetch(indices);
3812
3813        read_fused(
3814            indices,
3815            data_layer,
3816            CompressedSparseFormat::Csc,
3817            (n_rows, indices.len()),
3818            |gene, scratch, fragment| {
3819                if self.is_v4() {
3820                    let (chunk, _) = self.locate_chunk(gene)?;
3821                    return fragment.push_v4(chunk, &self.v4_ctx(), scratch, row_map.as_deref());
3822                }
3823                self.decompress_chunk_into(gene, &mut scratch.bytes)?;
3824                let p = csc_payload(&scratch.bytes)?;
3825                fragment.push_run(&p, row_map.as_deref())
3826            },
3827        )
3828    }
3829
3830    /// Read in genes by indices in a multi-threaded manner
3831    ///
3832    /// ### Params
3833    ///
3834    /// * `indices` - Slice of index positions of the genes to retrieve
3835    ///
3836    /// ### Returns
3837    ///
3838    /// Returns an array of `CscGeneChunk`.
3839    fn read_gene_parallel(&self, indices: &[usize]) -> Result<Vec<CscGeneChunk>, ScDataErrors> {
3840        if !self.is_gene_based() {
3841            return Err(ScDataErrors::ReaderModeMismatch {
3842                actual: "cell-based",
3843                requested: "gene-based",
3844            });
3845        }
3846
3847        self.prefetch(indices);
3848
3849        if self.is_v4() {
3850            let ctx = self.v4_ctx();
3851            return indices
3852                .par_iter()
3853                .map_init(V4Decoded::default, |scratch, &original_index| {
3854                    let (chunk, _) = self.locate_chunk(original_index)?;
3855                    v4_gene_chunk(chunk, &ctx, scratch)
3856                })
3857                .collect();
3858        }
3859
3860        indices
3861            .par_iter()
3862            .map(|&original_index| {
3863                let decompressed = self.decompress_chunk(original_index)?;
3864
3865                CscGeneChunk::read_from_buffer(&decompressed)
3866            })
3867            .collect()
3868    }
3869
3870    /// Read genes with raw counts and indices only.
3871    ///
3872    /// On v4 the norm layer is neither recomputed nor converted, `data_norm`
3873    /// comes back empty and the decoded indices move into the chunk. v3 reads
3874    /// both layers, as [`Self::read_gene_parallel`].
3875    ///
3876    /// ### Params
3877    ///
3878    /// * `indices` - Slice of index positions of the genes to retrieve
3879    ///
3880    /// ### Returns
3881    ///
3882    /// Returns an array of `CscGeneChunk` with an empty `data_norm` on v4.
3883    fn read_gene_parallel_raw(&self, indices: &[usize]) -> Result<Vec<CscGeneChunk>, ScDataErrors> {
3884        if !self.is_v4() {
3885            return self.read_gene_parallel(indices);
3886        }
3887        if !self.is_gene_based() {
3888            return Err(ScDataErrors::ReaderModeMismatch {
3889                actual: "cell-based",
3890                requested: "gene-based",
3891            });
3892        }
3893
3894        self.prefetch(indices);
3895
3896        let ctx = self.v4_ctx();
3897        indices
3898            .par_iter()
3899            .map_init(V4Decoded::default, |s, &original_index| {
3900                let (chunk, _) = self.locate_chunk(original_index)?;
3901                let h = decode_v4(chunk, &ctx, true, false, s)?;
3902                Ok(CscGeneChunk {
3903                    data_raw: RawCounts::from_u32_auto(&s.raw[..h.nnz]),
3904                    data_norm: Vec::new(),
3905                    avg_exp: F16::from_bits(h.aux as u16),
3906                    nnz: h.nnz,
3907                    indices: std::mem::take(&mut s.idx),
3908                    original_index: h.original_index,
3909                    to_keep: h.to_keep,
3910                })
3911            })
3912            .collect()
3913    }
3914
3915    /// Read in genes by indices in a multi-threaded manner
3916    ///
3917    /// And filter the genes by `cells_to_keep` in parallel. Overrides the
3918    /// default so that reading and filtering are fused into a single pass.
3919    ///
3920    /// ### Params
3921    ///
3922    /// * `indices` - Slice of index positions of the genes to retrieve
3923    /// * `cells_to_keep` - Cell index positions to keep, in output order
3924    ///
3925    /// ### Returns
3926    ///
3927    /// Returns an array of `CscGeneChunk`.
3928    fn read_gene_parallel_filtered(
3929        &self,
3930        indices: &[usize],
3931        cells_to_keep: &IndexSet<u32>,
3932    ) -> Result<Vec<CscGeneChunk>, ScDataErrors> {
3933        if !self.is_gene_based() {
3934            return Err(ScDataErrors::ReaderModeMismatch {
3935                actual: "cell-based",
3936                requested: "gene-based",
3937            });
3938        }
3939
3940        let cell_map = keep_set_index_map(cells_to_keep);
3941
3942        self.prefetch(indices);
3943
3944        if self.is_v4() {
3945            let ctx = self.v4_ctx();
3946            return indices
3947                .par_iter()
3948                .map_init(V4Decoded::default, |scratch, &original_index| {
3949                    let (chunk, _) = self.locate_chunk(original_index)?;
3950                    let mut chunk = v4_gene_chunk(chunk, &ctx, scratch)?;
3951                    chunk.filter_selected_cells_dense(&cell_map);
3952                    Ok(chunk)
3953                })
3954                .collect();
3955        }
3956
3957        indices
3958            .par_iter()
3959            .map(|&original_index| {
3960                let decompressed = self.decompress_chunk(original_index)?;
3961
3962                let mut chunk = CscGeneChunk::read_from_buffer(&decompressed)?;
3963
3964                chunk.filter_selected_cells_dense(&cell_map);
3965
3966                Ok(chunk)
3967            })
3968            .collect()
3969    }
3970
3971    /// Helper to return the header
3972    ///
3973    /// ### Returns
3974    ///
3975    /// Returns the header file
3976    fn get_header(&self) -> SparseDataHeader {
3977        self.header.clone()
3978    }
3979
3980    /// Read only library sizes for specified cells
3981    ///
3982    /// More efficient than the default, which parses whole chunks. Files
3983    /// with a library-size index read 8 bytes per cell from it; older files
3984    /// decompress each chunk and read the header bytes (offset 12-19)
3985    /// without deserialising the rest.
3986    ///
3987    /// ### Params
3988    ///
3989    /// * `indices` - Cell indices
3990    ///
3991    /// ### Returns
3992    ///
3993    /// Vector of library sizes
3994    fn read_cell_library_sizes(&self, indices: &[usize]) -> Result<Vec<usize>, ScDataErrors> {
3995        if !self.is_cell_based() {
3996            return Err(ScDataErrors::ReaderModeMismatch {
3997                actual: "gene-based",
3998                requested: "cell-based",
3999            });
4000        }
4001
4002        if let Some(start) = self.library_index {
4003            return self.library_sizes_from_index(start, indices);
4004        }
4005
4006        if self.is_v4() {
4007            return indices
4008                .iter()
4009                .map(|&original_index| {
4010                    let (chunk, _) = self.locate_chunk(original_index)?;
4011                    Ok(V4Header::parse(chunk)?.aux as usize)
4012                })
4013                .collect();
4014        }
4015
4016        indices
4017            .par_iter()
4018            .map(|&original_index| {
4019                let decompressed = self.decompress_chunk(original_index)?;
4020
4021                Ok(u64::from_le_bytes([
4022                    decompressed[12],
4023                    decompressed[13],
4024                    decompressed[14],
4025                    decompressed[15],
4026                    decompressed[16],
4027                    decompressed[17],
4028                    decompressed[18],
4029                    decompressed[19],
4030                ]) as usize)
4031            })
4032            .collect()
4033    }
4034
4035    /// Read the NNZ for specific genes
4036    ///
4037    /// Overrides the default, which parses whole chunks. Reads directly from
4038    /// the chunk header bytes (offset 16-23) instead.
4039    ///
4040    /// ### Params
4041    ///
4042    /// * `indices` - Gene indices
4043    ///
4044    /// ### Returns
4045    ///
4046    /// Vector of number of NNZ genes.
4047    fn read_gene_nnz(&self, indices: &[usize]) -> Result<Vec<usize>, ScDataErrors> {
4048        if !self.is_gene_based() {
4049            return Err(ScDataErrors::ReaderModeMismatch {
4050                actual: "cell-based",
4051                requested: "gene-based",
4052            });
4053        }
4054
4055        if self.is_v4() {
4056            return indices
4057                .iter()
4058                .map(|&original_index| {
4059                    let (chunk, _) = self.locate_chunk(original_index)?;
4060                    Ok(V4Header::parse(chunk)?.nnz)
4061                })
4062                .collect();
4063        }
4064
4065        indices
4066            .par_iter()
4067            .map(|&original_index| {
4068                let decompressed = self.decompress_chunk(original_index)?;
4069
4070                Ok(u64::from_le_bytes([
4071                    decompressed[16],
4072                    decompressed[17],
4073                    decompressed[18],
4074                    decompressed[19],
4075                    decompressed[20],
4076                    decompressed[21],
4077                    decompressed[22],
4078                    decompressed[23],
4079                ]) as usize)
4080            })
4081            .collect()
4082    }
4083
4084    /// Is the reader connected to a cell-based file?
4085    ///
4086    /// ### Returns
4087    ///
4088    /// Bool
4089    fn is_cell_based(&self) -> bool {
4090        self.header.cell_based
4091    }
4092
4093    /// Library size the `data_norm` layer of this file was scaled to.
4094    ///
4095    /// ### Returns
4096    ///
4097    /// `Some(target_size)`, or `None` for raw-only files and for files written
4098    /// before the field entered the header.
4099    fn target_size(&self) -> Option<f32> {
4100        (self.target_size != 0.0).then_some(self.target_size)
4101    }
4102}
4103
4104//////////////////////
4105// Chunks to sparse //
4106//////////////////////
4107
4108#[derive(Clone, Debug, Copy)]
4109/// Which of the data layers to return from a given chunk when transforming
4110/// into a [CompressedSparseData2]
4111pub enum DataLayerReturn {
4112    /// Return both layers, i.e., raw and
4113    BothLayers,
4114    /// Return only the raw layer
4115    Raw,
4116    /// Return only the norm layer
4117    Norm,
4118}
4119
4120/// Append a chunk's raw counts to `data`, converted exactly into `T`.
4121///
4122/// The `RawCounts` variant is matched once per chunk rather than per value.
4123///
4124/// ### Params
4125///
4126/// * `data` - Destination vector
4127/// * `raw` - The chunk's raw counts
4128///
4129/// ### Returns
4130///
4131/// `Ok(())`, or [`ScDataErrors::RawCountOverflow`] for a count that does not
4132/// fit `T`.
4133fn extend_raw_counts<T>(data: &mut Vec<T>, raw: &RawCounts) -> Result<(), ScDataErrors>
4134where
4135    T: BixverseNumeric + FromPrimitive,
4136{
4137    let convert = |val: u32| {
4138        T::from_u32(val).ok_or(ScDataErrors::RawCountOverflow {
4139            value: val,
4140            target_type: std::any::type_name::<T>(),
4141        })
4142    };
4143    match raw {
4144        RawCounts::U16(v) => {
4145            for &x in v {
4146                data.push(convert(x as u32)?);
4147            }
4148        }
4149        RawCounts::U32(v) => {
4150            for &x in v {
4151                data.push(convert(x)?);
4152            }
4153        }
4154    }
4155    Ok(())
4156}
4157
4158/// Converts a slice of gene chunks into a CSC sparse matrix
4159///
4160/// Constructs a cells x genes compressed sparse column matrix from individual
4161/// gene chunks. Depending on `data_layer`, the raw counts and/or the normalised
4162/// counts (f32) are populated.
4163///
4164/// Raw counts are stored on disk as `u32` and are converted into `T` exactly:
4165/// a count that does not fit returns [`ScDataErrors::RawCountOverflow`]
4166/// rather than saturating. Note that `T = f32` starts rounding above 2^24,
4167/// which is far beyond any plausible per-gene count.
4168///
4169/// ### Params
4170///
4171/// * `chunks` - Vector of gene chunks to convert
4172/// * `data_layer` - Which data layer(s) to populate in the output
4173/// * `n_cells` - Total number of cells in the dataset
4174///
4175/// ### Returns
4176///
4177/// A CSC-formatted sparse matrix with the requested layer(s) populated.
4178pub fn from_gene_chunks<T>(
4179    chunks: Vec<CscGeneChunk>,
4180    data_layer: &DataLayerReturn,
4181    n_cells: usize,
4182) -> Result<CompressedSparseData2<T, f32>, ScDataErrors>
4183where
4184    T: BixverseNumeric + FromPrimitive,
4185{
4186    let n_genes = chunks.len();
4187    let keep_raw = matches!(
4188        data_layer,
4189        DataLayerReturn::BothLayers | DataLayerReturn::Raw
4190    );
4191    let keep_norm = matches!(
4192        data_layer,
4193        DataLayerReturn::BothLayers | DataLayerReturn::Norm
4194    );
4195
4196    let total_nnz: usize = chunks.iter().map(|c| c.indices.len()).sum();
4197    let mut data: Vec<T> = Vec::with_capacity(if keep_raw { total_nnz } else { 0 });
4198    let mut data_2: Vec<f32> = Vec::with_capacity(if keep_norm { total_nnz } else { 0 });
4199    let mut indices: Vec<u32> = Vec::with_capacity(total_nnz);
4200    let mut indptr: Vec<usize> = Vec::with_capacity(n_genes + 1);
4201    indptr.push(0);
4202
4203    for chunk in chunks {
4204        if keep_raw {
4205            extend_raw_counts(&mut data, &chunk.data_raw)?;
4206        }
4207        if keep_norm {
4208            data_2.extend(chunk.data_norm.iter().map(|v| v.to_f32()));
4209        }
4210        indices.extend_from_slice(&chunk.indices);
4211        indptr.push(indices.len());
4212    }
4213
4214    Ok(CompressedSparseData2 {
4215        data,
4216        indices,
4217        indptr: indptr.index_cast(),
4218        cs_type: CompressedSparseFormat::Csc,
4219        data_2: if keep_norm { Some(data_2) } else { None },
4220        shape: (n_cells, n_genes),
4221    })
4222}
4223
4224/// Converts a slice of cell chunks into a CSR sparse matrix
4225///
4226/// Constructs a cells x genes compressed sparse row matrix from individual
4227/// cell chunks. Same exact-conversion behaviour as [`from_gene_chunks`]: a raw
4228/// count that does not fit `T` returns [`ScDataErrors::RawCountOverflow`]
4229/// rather than saturating.
4230///
4231/// ### Params
4232///
4233/// * `chunks` - Slice of cell chunks to convert
4234/// * `data_layer` - Which data layer(s) to populate in the output
4235/// * `n_genes` - Total number of genes in the dataset
4236///
4237/// ### Returns
4238///
4239/// A CSR-formatted sparse matrix with the requested layer(s) populated.
4240pub fn from_cell_chunks<T>(
4241    chunks: &[CsrCellChunk],
4242    data_layer: &DataLayerReturn,
4243    n_genes: usize,
4244) -> Result<CompressedSparseData2<T, f32>, ScDataErrors>
4245where
4246    T: BixverseNumeric + FromPrimitive,
4247{
4248    let n_cells = chunks.len();
4249    let keep_raw = matches!(
4250        data_layer,
4251        DataLayerReturn::BothLayers | DataLayerReturn::Raw
4252    );
4253    let keep_norm = matches!(
4254        data_layer,
4255        DataLayerReturn::BothLayers | DataLayerReturn::Norm
4256    );
4257
4258    let total_nnz: usize = chunks.iter().map(|c| c.indices.len()).sum();
4259    let mut data: Vec<T> = Vec::with_capacity(if keep_raw { total_nnz } else { 0 });
4260    let mut data_2: Vec<f32> = Vec::with_capacity(if keep_norm { total_nnz } else { 0 });
4261    let mut indices: Vec<u32> = Vec::with_capacity(total_nnz);
4262    let mut indptr: Vec<usize> = Vec::with_capacity(n_cells + 1);
4263    indptr.push(0);
4264
4265    for chunk in chunks {
4266        if keep_raw {
4267            extend_raw_counts(&mut data, &chunk.data_raw)?;
4268        }
4269        if keep_norm {
4270            data_2.extend(chunk.data_norm.iter().map(|v| v.to_f32()));
4271        }
4272        indices.extend_from_slice(&chunk.indices);
4273        indptr.push(indices.len());
4274    }
4275
4276    Ok(CompressedSparseData2 {
4277        data,
4278        indices,
4279        indptr: indptr.index_cast(),
4280        cs_type: CompressedSparseFormat::Csr,
4281        data_2: if keep_norm { Some(data_2) } else { None },
4282        shape: (n_cells, n_genes),
4283    })
4284}
4285
4286/////////////////////////////
4287// Chunk transforms for sc //
4288/////////////////////////////
4289
4290impl CscGeneChunk {
4291    /// Convert the normalised counts from the `log1p(u * sf)` scale to the
4292    /// `log1p(u)` scale, enabling downstream use of the shifted CLR (PFlog1pPF)
4293    /// transformation. Mutates `data_norm` in place and refreshes `avg_exp`.
4294    ///
4295    /// ### Params
4296    ///
4297    /// * `size_factor` - The size factor used in the original normalisation
4298    ///   (e.g. 1e4 for CP10k).
4299    pub fn transform_to_clr(&mut self, size_factor: f32) {
4300        let sf_inv = 1.0_f32 / size_factor;
4301        let mut new_sum = 0.0_f32;
4302        for v in self.data_norm.iter_mut() {
4303            let new_val = (v.to_f32().exp_m1() * sf_inv).ln_1p();
4304            new_sum += new_val;
4305            *v = F16::from(f16::from_f32(new_val));
4306        }
4307        self.avg_exp = F16::from(f16::from_f32(new_sum));
4308    }
4309}
4310
4311impl CsrCellChunk {
4312    /// Combine two cells into a synthetic doublet for doublet simulation.
4313    ///
4314    /// Filters both cells to HVG genes, sums their raw counts, normalises
4315    /// to `target_size` using the combined HVG library size, and optionally
4316    /// log-transforms.
4317    ///
4318    /// ### Params
4319    ///
4320    /// * `cell1` - First parent cell.
4321    /// * `cell2` - Second parent cell.
4322    /// * `hvg_indices` - Set of HVG gene indices to retain.
4323    /// * `hvg_lib_size_combined` - Sum of parent HVG library sizes.
4324    /// * `target_size` - Normalisation target (e.g. mean HVG library size).
4325    /// * `log_transform` - Whether to apply `ln(1 + x)` after normalisation.
4326    /// * `doublet_index` - Index to assign to the new chunk.
4327    ///
4328    /// ### Returns
4329    ///
4330    /// A new `CsrCellChunk` representing the synthetic doublet.
4331    #[allow(clippy::too_many_arguments)]
4332    pub fn add_cells_scrublet(
4333        cell1: &CsrCellChunk,
4334        cell2: &CsrCellChunk,
4335        hvg_indices: &FxHashSet<usize>,
4336        hvg_lib_size_combined: usize,
4337        target_size: f32,
4338        log_transform: bool,
4339        doublet_index: usize,
4340    ) -> Self {
4341        let mut gene_counts: FxHashMap<u32, u32> = FxHashMap::default();
4342
4343        for i in 0..cell1.indices.len() {
4344            if hvg_indices.contains(&(cell1.indices[i] as usize)) {
4345                *gene_counts.entry(cell1.indices[i]).or_insert(0) += cell1.data_raw.get(i);
4346            }
4347        }
4348        for i in 0..cell2.indices.len() {
4349            if hvg_indices.contains(&(cell2.indices[i] as usize)) {
4350                *gene_counts.entry(cell2.indices[i]).or_insert(0) += cell2.data_raw.get(i);
4351            }
4352        }
4353
4354        let mut gene_vec: Vec<(u32, u32)> = gene_counts.into_iter().collect();
4355        gene_vec.sort_unstable_by_key(|&(gene, _)| gene);
4356
4357        let mut data_raw: Vec<u32> = Vec::with_capacity(gene_vec.len());
4358        let mut data_norm = Vec::with_capacity(gene_vec.len());
4359        let mut indices = Vec::with_capacity(gene_vec.len());
4360
4361        let norm_factor = target_size / hvg_lib_size_combined as f32;
4362
4363        for (gene, count) in gene_vec {
4364            data_raw.push(count);
4365
4366            let normalised = if log_transform {
4367                (count as f32 * norm_factor).ln_1p()
4368            } else {
4369                count as f32 * norm_factor
4370            };
4371            data_norm.push(F16::from(f16::from_f32(
4372                normalised.clamp(-65504.0, 65504.0),
4373            )));
4374            indices.push(gene);
4375        }
4376
4377        Self {
4378            data_raw: RawCounts::from_u32_auto(&data_raw),
4379            data_norm,
4380            library_size: hvg_lib_size_combined,
4381            indices,
4382            original_index: doublet_index,
4383            to_keep: true,
4384        }
4385    }
4386}
4387
4388impl CsrCellChunk {
4389    /// Construct a chunk from pre-computed doublet simulation data.
4390    ///
4391    /// Unlike `from_data`, this does not recompute normalisation! The caller
4392    /// has already applied the correct normalisation (with noise-injected raw
4393    /// counts and the effective library size).
4394    ///
4395    /// ### Params
4396    ///
4397    /// * `indices` - Selected genes-remapped gene positions (sparse, sorted).
4398    /// * `raw_counts` - Raw counts at each position (post-noise).
4399    /// * `norm_values` - Pre-computed normalised values (same length
4400    ///   as `indices`).
4401    /// * `effective_lib_size` - Sum of raw counts after all noise
4402    ///   treatments.
4403    /// * `doublet_index` - Index of this doublet in the simulation batch (used
4404    ///   as `original_index`).
4405    ///
4406    /// ### Returns
4407    ///
4408    /// A `CsrCellChunk` ready for downstream PCA and feature
4409    /// engineering.
4410    pub fn from_doublet_simulation(
4411        indices: Vec<u32>,
4412        raw_counts: Vec<u32>,
4413        norm_values: Vec<f32>,
4414        effective_lib_size: usize,
4415        doublet_index: usize,
4416    ) -> Self {
4417        debug_assert_eq!(indices.len(), raw_counts.len());
4418        debug_assert_eq!(indices.len(), norm_values.len());
4419
4420        let data_raw = RawCounts::from_u32_auto(&raw_counts);
4421        let data_norm: Vec<F16> = norm_values
4422            .iter()
4423            .map(|&v| F16::from(f16::from_f32(v)))
4424            .collect();
4425
4426        Self {
4427            data_raw,
4428            data_norm,
4429            library_size: effective_lib_size,
4430            indices,
4431            original_index: doublet_index,
4432            to_keep: true,
4433        }
4434    }
4435}
4436
4437///////////
4438// Tests //
4439///////////
4440
4441#[cfg(test)]
4442mod tests {
4443    use super::*;
4444    use half::f16;
4445
4446    /// RAII guard that removes a test's temp file even if an assert fails.
4447    struct TempBin(std::path::PathBuf);
4448
4449    /// Drop implementation for [`TempBin`]. Errors are ignored: the file may
4450    /// already be gone, and this runs during unwind.
4451    impl Drop for TempBin {
4452        fn drop(&mut self) {
4453            let _ = std::fs::remove_file(&self.0);
4454        }
4455    }
4456
4457    impl TempBin {
4458        /// Reserve a uniquely named scratch file in the system temp directory.
4459        ///
4460        /// ### Params
4461        ///
4462        /// * `name` - Test-unique suffix.
4463        ///
4464        /// ### Returns
4465        ///
4466        /// The guard; the path is available via [`Self::path`].
4467        fn new(name: &str) -> Self {
4468            Self(std::env::temp_dir().join(format!("bixverse_data_io_{name}.bin")))
4469        }
4470
4471        /// Path of the guarded file as a `&str`.
4472        fn path(&self) -> &str {
4473            self.0.to_str().expect("temp path is valid UTF-8")
4474        }
4475    }
4476
4477    /// Build a cell chunk from raw counts and gene indices.
4478    ///
4479    /// ### Params
4480    ///
4481    /// * `raw` - Raw counts, converted via [`RawCounts::from_u32_auto`].
4482    /// * `indices` - Gene indices, same length as `raw`.
4483    /// * `original_index` - Index the chunk is stored under.
4484    ///
4485    /// ### Returns
4486    ///
4487    /// The `CsrCellChunk`.
4488    fn cell_chunk(raw: &[u32], indices: &[u32], original_index: usize) -> CsrCellChunk {
4489        CsrCellChunk {
4490            data_raw: RawCounts::from_u32_auto(raw),
4491            data_norm: raw
4492                .iter()
4493                .map(|&v| F16::from(f16::from_f32(v as f32)))
4494                .collect(),
4495            library_size: raw.iter().map(|&v| v as usize).sum(),
4496            indices: indices.to_vec(),
4497            original_index,
4498            to_keep: true,
4499        }
4500    }
4501
4502    /// Build a gene chunk from raw counts and cell indices.
4503    ///
4504    /// ### Params
4505    ///
4506    /// * `raw` - Raw counts, converted via [`RawCounts::from_u32_auto`].
4507    /// * `indices` - Cell indices, same length as `raw`.
4508    /// * `original_index` - Index the chunk is stored under.
4509    ///
4510    /// ### Returns
4511    ///
4512    /// The `CscGeneChunk`.
4513    fn gene_chunk(raw: &[u32], indices: &[u32], original_index: usize) -> CscGeneChunk {
4514        CscGeneChunk {
4515            data_raw: RawCounts::from_u32_auto(raw),
4516            data_norm: raw
4517                .iter()
4518                .map(|&v| F16::from(f16::from_f32(v as f32)))
4519                .collect(),
4520            avg_exp: F16::from(f16::from_f32(1.0)),
4521            nnz: raw.len(),
4522            indices: indices.to_vec(),
4523            original_index,
4524            to_keep: true,
4525        }
4526    }
4527
4528    ////////////////////
4529    // Cell filtering //
4530    ////////////////////
4531
4532    /// Kept cells are renumbered to their keep-set position and ordered by it,
4533    /// also when the keep set is not ascending; both filter paths agree.
4534    #[test]
4535    fn test_filter_selected_cells_follows_keep_order() {
4536        let keep: IndexSet<u32> = [7_u32, 1, 4, 900].into_iter().collect();
4537
4538        let mut by_set = gene_chunk(&[10, 30, 50, 70], &[1, 3, 5, 7], 0);
4539        by_set.filter_selected_cells(&keep);
4540        assert_eq!(by_set.indices, vec![0, 1]);
4541        assert_eq!(by_set.data_raw.iter().collect::<Vec<u32>>(), vec![70, 10]);
4542        assert_eq!(by_set.data_norm[0].to_f32(), 70.0);
4543        assert_eq!(by_set.nnz, 2);
4544
4545        // cell 900 sizes the map; cells past it must drop, not index out
4546        let mut by_map = gene_chunk(&[10, 30, 50, 70, 90], &[1, 3, 5, 7, 2_000], 0);
4547        by_map.filter_selected_cells_dense(&keep_set_index_map(&keep));
4548        assert_eq!(by_map.indices, by_set.indices);
4549        assert_eq!(
4550            by_map.data_raw.iter().collect::<Vec<u32>>(),
4551            by_set.data_raw.iter().collect::<Vec<u32>>()
4552        );
4553        assert_eq!(by_map.data_norm, by_set.data_norm);
4554    }
4555
4556    ////////////////////
4557    // Chunk codec //
4558    ////////////////////
4559
4560    /// Pins the CSR chunk codec: every field must return bit-identical.
4561    #[test]
4562    fn test_cell_chunk_round_trip_preserves_fields() {
4563        let raw = [1_u32, 7, 42, 65_535, 3];
4564        let indices = [0_u32, 5, 9, 100_000, 300_000];
4565        let chunk = cell_chunk(&raw, &indices, 17);
4566
4567        let mut buffer = Vec::new();
4568        chunk.write_to_bytes(&mut buffer).expect("serialise");
4569        let back = CsrCellChunk::read_from_buffer(&buffer).expect("deserialise");
4570
4571        assert_eq!(back.data_raw.iter().collect::<Vec<_>>(), raw.to_vec());
4572        assert_eq!(back.indices, indices.to_vec());
4573        assert_eq!(back.library_size, chunk.library_size);
4574        assert_eq!(back.original_index, 17);
4575        assert!(back.to_keep);
4576        let bits: Vec<u16> = back.data_norm.iter().map(|v| v.to_bits()).collect();
4577        let want: Vec<u16> = chunk.data_norm.iter().map(|v| v.to_bits()).collect();
4578        assert_eq!(bits, want);
4579    }
4580
4581    /// Same pin for the CSC codec, which carries `nnz` and `avg_exp` too.
4582    #[test]
4583    fn test_gene_chunk_round_trip_preserves_fields() {
4584        let raw = [2_u32, 0, 99, 70_000];
4585        let indices = [1_u32, 4, 8, 12];
4586        let chunk = gene_chunk(&raw, &indices, 3);
4587
4588        let mut buffer = Vec::new();
4589        chunk.write_to_bytes(&mut buffer).expect("serialise");
4590        let back = CscGeneChunk::read_from_buffer(&buffer).expect("deserialise");
4591
4592        assert_eq!(back.data_raw.iter().collect::<Vec<_>>(), raw.to_vec());
4593        assert_eq!(back.indices, indices.to_vec());
4594        assert_eq!(back.nnz, 4);
4595        assert_eq!(back.original_index, 3);
4596        assert_eq!(back.avg_exp.to_bits(), chunk.avg_exp.to_bits());
4597    }
4598
4599    /// Regression: the CSC guard used to check 32 bytes then slice 36.
4600    #[test]
4601    fn test_gene_chunk_short_buffer_errors_instead_of_panicking() {
4602        for len in CSR_CHUNK_HEADER_LEN..CSC_CHUNK_HEADER_LEN {
4603            let buffer = vec![0_u8; len];
4604            match CscGeneChunk::read_from_buffer(&buffer) {
4605                Err(ScDataErrors::ChunkBufferTooSmall { expected, found }) => {
4606                    assert_eq!(expected, CSC_CHUNK_HEADER_LEN);
4607                    assert_eq!(found, len);
4608                }
4609                other => panic!("expected ChunkBufferTooSmall for len {len}, got {other:?}"),
4610            }
4611        }
4612    }
4613
4614    /// Regression: payload lengths came from untrusted header fields and were
4615    /// never validated against the buffer.
4616    #[test]
4617    fn test_chunk_payload_truncation_is_caught() {
4618        let chunk = cell_chunk(&[1, 2, 3], &[0, 1, 2], 0);
4619        let mut buffer = Vec::new();
4620        chunk.write_to_bytes(&mut buffer).expect("serialise");
4621
4622        // Claim four times as many raw counts as were written.
4623        buffer[0..4].copy_from_slice(&12_u32.to_le_bytes());
4624
4625        assert!(matches!(
4626            CsrCellChunk::read_from_buffer(&buffer),
4627            Err(ScDataErrors::ChunkPayloadTruncated { .. })
4628        ));
4629
4630        // Same for a gene chunk, via the indices length.
4631        let gene = gene_chunk(&[1, 2, 3], &[0, 1, 2], 0);
4632        let mut buffer = Vec::new();
4633        gene.write_to_bytes(&mut buffer).expect("serialise");
4634        buffer[8..12].copy_from_slice(&64_u32.to_le_bytes());
4635
4636        assert!(matches!(
4637            CscGeneChunk::read_from_buffer(&buffer),
4638            Err(ScDataErrors::ChunkPayloadTruncated { .. })
4639        ));
4640    }
4641
4642    /// Regression: with `elem_size == 2` and an odd raw/norm element count the
4643    /// old `*const u32` read landed on a 2-byte-aligned address.
4644    #[test]
4645    fn test_chunk_round_trip_with_unaligned_index_offset() {
4646        // 3 raw u16 + 3 norm u16 puts the indices at offset 32 + 6 + 6 = 44,
4647        // which is 4-byte misaligned.
4648        let raw = [1_u32, 2, 3];
4649        let indices = [7_u32, 8, 9];
4650        let chunk = cell_chunk(&raw, &indices, 0);
4651        assert_eq!(chunk.data_raw.elem_size(), RAW_ELEM_U16);
4652
4653        let mut buffer = Vec::new();
4654        chunk.write_to_bytes(&mut buffer).expect("serialise");
4655        assert_eq!(buffer.len() % 4, 0);
4656
4657        let back = CsrCellChunk::read_from_buffer(&buffer).expect("deserialise");
4658        assert_eq!(back.indices, indices.to_vec());
4659        assert_eq!(back.data_raw.iter().collect::<Vec<_>>(), raw.to_vec());
4660    }
4661
4662    /// A zero discriminant still means u16; an unknown width must error.
4663    #[test]
4664    fn test_raw_counts_element_size_discriminant() {
4665        let payload = [1_u8, 0, 2, 0];
4666
4667        // Legacy files left the discriminant byte as zero padding.
4668        let legacy = RawCounts::read_from_buffer(&payload, 2, 0).expect("legacy parses as u16");
4669        assert_eq!(legacy.iter().collect::<Vec<_>>(), vec![1, 2]);
4670
4671        let explicit =
4672            RawCounts::read_from_buffer(&payload, 2, RAW_ELEM_U16).expect("explicit u16 parses");
4673        assert_eq!(explicit.iter().collect::<Vec<_>>(), vec![1, 2]);
4674
4675        assert!(matches!(
4676            RawCounts::read_from_buffer(&payload, 2, 3),
4677            Err(ScDataErrors::RawElemSizeInvalid(3))
4678        ));
4679    }
4680
4681    /////////////////////
4682    // Count widening //
4683    /////////////////////
4684
4685    /// Regression: counts above `u16::MAX` used to saturate silently.
4686    #[test]
4687    fn test_large_raw_count_survives_round_trip() {
4688        let raw = [70_000_u32, 1];
4689        let chunk = cell_chunk(&raw, &[0, 1], 0);
4690        assert_eq!(chunk.data_raw.elem_size(), RAW_ELEM_U32);
4691
4692        let mut buffer = Vec::new();
4693        chunk.write_to_bytes(&mut buffer).expect("serialise");
4694        let back = CsrCellChunk::read_from_buffer(&buffer).expect("deserialise");
4695
4696        let csr = from_cell_chunks::<u32>(&[back], &DataLayerReturn::Raw, 2).expect("assemble");
4697        assert_eq!(csr.data, vec![70_000_u32, 1]);
4698    }
4699
4700    /// A count above `u16::MAX` must be reported, not wrapped or saturated.
4701    #[test]
4702    fn test_narrow_target_type_reports_overflow() {
4703        let chunk = gene_chunk(&[70_000, 1], &[0, 1], 0);
4704
4705        match from_gene_chunks::<u16>(vec![chunk], &DataLayerReturn::Raw, 2) {
4706            Err(ScDataErrors::RawCountOverflow { value, .. }) => assert_eq!(value, 70_000),
4707            other => panic!("expected RawCountOverflow, got {:?}", other.map(|_| ())),
4708        }
4709    }
4710
4711    ///////////////////////
4712    // Writer and reader //
4713    ///////////////////////
4714
4715    /// Writer to reader round trip: orientation, target size and both chunks.
4716    #[test]
4717    fn test_cell_based_file_round_trip() {
4718        let temp = TempBin::new("cell_round_trip");
4719        let chunks = vec![
4720            cell_chunk(&[5, 70_000], &[0, 2], 0),
4721            cell_chunk(&[1, 2, 3], &[0, 1, 2], 1),
4722        ];
4723
4724        let mut writer =
4725            CellGeneSparseWriter::new(temp.path(), true, 2, 3, 1e4).expect("writer opens");
4726        for chunk in chunks {
4727            writer.write_cell_chunk(chunk).expect("write");
4728        }
4729        writer.finalise().expect("finalise");
4730
4731        let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
4732        assert_eq!(reader.target_size(), Some(1e4));
4733        assert!(reader.is_cell_based());
4734
4735        let back = reader.read_cells_parallel(&[0, 1]).expect("read");
4736        assert_eq!(back.len(), 2);
4737        assert_eq!(back[0].data_raw.iter().collect::<Vec<_>>(), vec![5, 70_000]);
4738        assert_eq!(back[1].indices, vec![0, 1, 2]);
4739    }
4740
4741    /// Write a small cell-major store and hand back the guard and reader.
4742    ///
4743    /// The chunks deliberately differ in length and include a count above
4744    /// `u16::MAX`, so the `u32` raw element width is exercised alongside the
4745    /// row-length bookkeeping.
4746    ///
4747    /// ### Params
4748    ///
4749    /// * `name` - Test-unique file suffix.
4750    ///
4751    /// ### Returns
4752    ///
4753    /// The temp guard and an open reader over it.
4754    fn csr_test_store(name: &str) -> (TempBin, ParallelSparseReader) {
4755        csr_test_store_with(name, false)
4756    }
4757
4758    /// [`csr_test_store`] in the legacy v3 format when `legacy` is set.
4759    fn csr_test_store_with(name: &str, legacy: bool) -> (TempBin, ParallelSparseReader) {
4760        let temp = TempBin::new(name);
4761        let chunks = vec![
4762            cell_chunk(&[5, 70_000], &[0, 2], 0),
4763            cell_chunk(&[1, 2, 3], &[0, 1, 2], 1),
4764            cell_chunk(&[9], &[3], 2),
4765        ];
4766
4767        let ctor = if legacy {
4768            CellGeneSparseWriter::new_v3
4769        } else {
4770            CellGeneSparseWriter::new
4771        };
4772        let mut writer = ctor(temp.path(), true, 3, 4, 1e4).expect("writer opens");
4773        for chunk in chunks {
4774            writer.write_cell_chunk(chunk).expect("write");
4775        }
4776        writer.finalise().expect("finalise");
4777
4778        let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
4779        (temp, reader)
4780    }
4781
4782    #[test]
4783    fn test_read_cells_csr_matches_chunk_path() {
4784        let (_temp, reader) = csr_test_store("csr_matches_chunks");
4785        let order = [2usize, 0, 1];
4786
4787        let batch = reader
4788            .read_cells_csr(&order, &DataLayerReturn::BothLayers)
4789            .expect("csr batch");
4790        let chunks = reader.read_cells_parallel(&order).expect("chunk batch");
4791
4792        assert_eq!(batch.shape, (3, 4));
4793        assert_eq!(batch.indptr.len(), order.len() + 1);
4794        assert_eq!(batch.indptr[0], 0);
4795        assert_eq!(
4796            *batch.indptr.last().expect("non-empty"),
4797            batch.indices.len() as u32
4798        );
4799
4800        let raw = batch.data_raw.as_ref().expect("raw requested");
4801        let norm = batch.data_norm.as_ref().expect("norm requested");
4802
4803        for (row, chunk) in chunks.iter().enumerate() {
4804            let lo = batch.indptr[row] as usize;
4805            let hi = batch.indptr[row + 1] as usize;
4806
4807            assert_eq!(&batch.indices[lo..hi], &chunk.indices[..]);
4808            assert_eq!(
4809                raw[lo..hi].to_vec(),
4810                chunk.data_raw.iter().map(|v| v as f32).collect::<Vec<_>>()
4811            );
4812            assert_eq!(
4813                norm[lo..hi].to_vec(),
4814                chunk
4815                    .data_norm
4816                    .iter()
4817                    .map(|v| v.to_f32())
4818                    .collect::<Vec<_>>()
4819            );
4820        }
4821    }
4822
4823    #[test]
4824    fn test_read_cells_csr_layer_selection() {
4825        let (_temp, reader) = csr_test_store("csr_layers");
4826
4827        let raw_only = reader
4828            .read_cells_csr(&[0, 1, 2], &DataLayerReturn::Raw)
4829            .expect("raw batch");
4830        assert!(raw_only.data_raw.is_some());
4831        assert!(raw_only.data_norm.is_none());
4832
4833        let norm_only = reader
4834            .read_cells_csr(&[0, 1, 2], &DataLayerReturn::Norm)
4835            .expect("norm batch");
4836        assert!(norm_only.data_raw.is_none());
4837        assert!(norm_only.data_norm.is_some());
4838    }
4839
4840    #[test]
4841    fn test_read_cells_csr_repeats_and_subsets() {
4842        let (_temp, reader) = csr_test_store("csr_repeats");
4843
4844        // A cell may legitimately appear twice in one batch: sampling with
4845        // replacement is a normal thing for a training loop to ask for.
4846        let batch = reader
4847            .read_cells_csr(&[1, 1], &DataLayerReturn::Raw)
4848            .expect("csr batch");
4849
4850        assert_eq!(batch.shape.0, 2);
4851        assert_eq!(batch.indptr, vec![0, 3, 6]);
4852        assert_eq!(batch.indices, vec![0, 1, 2, 0, 1, 2]);
4853    }
4854
4855    #[test]
4856    fn test_read_cells_csr_rejects_unknown_cell() {
4857        let (_temp, reader) = csr_test_store("csr_unknown_cell");
4858
4859        let err = reader
4860            .read_cells_csr(&[99], &DataLayerReturn::Raw)
4861            .expect_err("index is out of range");
4862        assert!(matches!(err, ScDataErrors::ChunkIndexNotFound(99)));
4863    }
4864
4865    #[test]
4866    fn test_read_cells_csr_rejects_gene_major_store() {
4867        let temp = TempBin::new("csr_gene_major");
4868        let mut writer =
4869            CellGeneSparseWriter::new(temp.path(), false, 2, 1, 1e4).expect("writer opens");
4870        writer
4871            .write_gene_chunk(gene_chunk(&[1, 2], &[0, 1], 0))
4872            .expect("write");
4873        writer.finalise().expect("finalise");
4874
4875        let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
4876        let err = reader
4877            .read_cells_csr(&[0], &DataLayerReturn::Raw)
4878            .expect_err("store is gene-major");
4879        assert!(matches!(err, ScDataErrors::ReaderModeMismatch { .. }));
4880    }
4881
4882    /// Write a small gene-major store over six cells.
4883    ///
4884    /// Gene 0 carries a count above `u16::MAX` (u32 raw width), gene 3 is
4885    /// empty, and the rest differ in length.
4886    ///
4887    /// ### Params
4888    ///
4889    /// * `name` - Test-unique file suffix.
4890    ///
4891    /// ### Returns
4892    ///
4893    /// The temp guard and an open reader over it.
4894    fn csc_test_store(name: &str) -> (TempBin, ParallelSparseReader) {
4895        let temp = TempBin::new(name);
4896        let chunks = vec![
4897            gene_chunk(&[5, 70_000, 2], &[0, 3, 5], 0),
4898            gene_chunk(&[1, 2, 3, 4, 5, 6], &[0, 1, 2, 3, 4, 5], 1),
4899            gene_chunk(&[9, 11], &[1, 4], 2),
4900            gene_chunk(&[], &[], 3),
4901        ];
4902
4903        let mut writer =
4904            CellGeneSparseWriter::new(temp.path(), false, 6, 4, 1e4).expect("writer opens");
4905        for chunk in chunks {
4906            writer.write_gene_chunk(chunk).expect("write");
4907        }
4908        writer.finalise().expect("finalise");
4909
4910        let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
4911        (temp, reader)
4912    }
4913
4914    /// Assert two sparse matrices are identical, field by field.
4915    ///
4916    /// ### Params
4917    ///
4918    /// * `got` - Fused output
4919    /// * `want` - Chunk-path reference
4920    fn assert_same_sparse<T: BixverseNumeric + std::fmt::Debug + PartialEq>(
4921        got: &CompressedSparseData2<T, f32>,
4922        want: &CompressedSparseData2<T, f32>,
4923    ) {
4924        assert_eq!(got.shape, want.shape);
4925        assert_eq!(got.cs_type.is_csc(), want.cs_type.is_csc());
4926        assert_eq!(got.indptr, want.indptr);
4927        assert_eq!(got.indices, want.indices);
4928        assert_eq!(got.data, want.data);
4929        assert_eq!(got.data_2, want.data_2);
4930    }
4931
4932    /// Fused gene reads equal the chunk path for every layer, with and
4933    /// without a cell subset, including a non-ascending, an empty and a
4934    /// repeated-gene case.
4935    #[test]
4936    fn test_read_genes_sparse_matches_chunk_path() {
4937        let (_temp, reader) = csc_test_store("csc_matches_chunks");
4938        let genes = [2usize, 0, 3, 1, 0];
4939
4940        let subsets: Vec<Option<IndexSet<u32>>> = vec![
4941            None,
4942            Some([0_u32, 3, 4, 5].into_iter().collect()),
4943            Some([5_u32, 1, 3].into_iter().collect()),
4944            Some(IndexSet::new()),
4945        ];
4946
4947        for layer in [
4948            DataLayerReturn::Raw,
4949            DataLayerReturn::Norm,
4950            DataLayerReturn::BothLayers,
4951        ] {
4952            for subset in &subsets {
4953                let (chunks, n_cells) = match subset {
4954                    Some(set) => (
4955                        reader
4956                            .read_gene_parallel_filtered(&genes, set)
4957                            .expect("chunks"),
4958                        set.len(),
4959                    ),
4960                    None => (reader.read_gene_parallel(&genes).expect("chunks"), 6),
4961                };
4962                let want = from_gene_chunks::<u32>(chunks, &layer, n_cells).expect("reference");
4963                let got = reader
4964                    .read_genes_sparse::<u32>(&genes, subset.as_ref(), &layer)
4965                    .expect("fused");
4966                assert_same_sparse(&got, &want);
4967            }
4968        }
4969    }
4970
4971    /// Fused cell reads equal the chunk path for a non-f32 target.
4972    #[test]
4973    fn test_read_cells_sparse_matches_chunk_path() {
4974        let (_temp, reader) = csr_test_store("csr_sparse_matches_chunks");
4975        let order = [2usize, 0, 1, 0];
4976
4977        for layer in [
4978            DataLayerReturn::Raw,
4979            DataLayerReturn::Norm,
4980            DataLayerReturn::BothLayers,
4981        ] {
4982            let chunks = reader.read_cells_parallel(&order).expect("chunks");
4983            let want = from_cell_chunks::<u32>(&chunks, &layer, 4).expect("reference");
4984            let got = reader
4985                .read_cells_sparse::<u32>(&order, &layer)
4986                .expect("fused");
4987            assert_same_sparse(&got, &want);
4988        }
4989    }
4990
4991    /// A count above the target type's range is an error, as on the chunk
4992    /// path, on both the unfiltered (bulk) and filtered paths, also when it
4993    /// sits past a dropped cell.
4994    #[test]
4995    fn test_read_genes_sparse_reports_overflow() {
4996        let (_temp, reader) = csc_test_store("csc_overflow");
4997        let keep: IndexSet<u32> = [3_u32].into_iter().collect();
4998
4999        for cells in [None, Some(&keep)] {
5000            match reader.read_genes_sparse::<u16>(&[0], cells, &DataLayerReturn::Raw) {
5001                Err(ScDataErrors::RawCountOverflow { value, .. }) => assert_eq!(value, 70_000),
5002                other => panic!("expected RawCountOverflow, got {:?}", other.map(|_| ())),
5003            }
5004        }
5005
5006        // Dropping the offending cell makes the narrow type fine.
5007        let keep: IndexSet<u32> = [0_u32, 5].into_iter().collect();
5008        let csc = reader
5009            .read_genes_sparse::<u16>(&[0], Some(&keep), &DataLayerReturn::Raw)
5010            .expect("fits u16");
5011        assert_eq!(csc.data, vec![5, 2]);
5012    }
5013
5014    #[test]
5015    fn test_read_genes_sparse_rejects_cell_major_store() {
5016        let (_temp, reader) = csr_test_store("csc_cell_major");
5017        let err = reader
5018            .read_genes_sparse::<f32>(&[0], None, &DataLayerReturn::Raw)
5019            .expect_err("store is cell-major");
5020        assert!(matches!(err, ScDataErrors::ReaderModeMismatch { .. }));
5021    }
5022
5023    #[test]
5024    fn test_decompress_chunk_into_matches_allocating_path() {
5025        let (_temp, reader) = csr_test_store_with("csr_scratch", true);
5026
5027        // A dirty buffer must be overwritten, not appended to: the scratch is
5028        // reused across every cell a worker owns.
5029        let mut scratch = vec![0xAAu8; 4096];
5030        for cell in 0..3 {
5031            reader
5032                .decompress_chunk_into(cell, &mut scratch)
5033                .expect("scratch decompress");
5034            let owned = reader.decompress_chunk(cell).expect("owned decompress");
5035            assert_eq!(scratch, owned);
5036        }
5037    }
5038
5039    #[test]
5040    fn test_identity_index_map_is_detected() {
5041        let (_temp, reader) = csr_test_store("csr_identity_map");
5042        assert!(reader.identity_index_map);
5043        assert_eq!(reader.chunk_index_of(2).expect("in range"), 2);
5044    }
5045
5046    /// Rewrite the 64-byte header slot of an existing file, applying `edit` to
5047    /// the decoded [`FileHeader`]. Lets a test corrupt one field of an otherwise
5048    /// valid file without hand-assembling the whole layout.
5049    fn patch_file_header(path: &str, edit: impl FnOnce(&mut FileHeader)) {
5050        let mut bytes = std::fs::read(path).expect("read back");
5051        let (mut header, _) = decode_from_slice::<FileHeader, _>(&bytes[0..64], config::standard())
5052            .expect("header decodes");
5053
5054        edit(&mut header);
5055
5056        let encoded = encode_to_vec(&header, config::standard()).expect("header encodes");
5057        let mut slot = [0_u8; 64];
5058        slot[..encoded.len()].copy_from_slice(&encoded);
5059        bytes[0..64].copy_from_slice(&slot);
5060
5061        std::fs::write(path, bytes).expect("write back");
5062    }
5063
5064    /// Build a minimal but valid cell-based file at `path`.
5065    fn write_valid_file(path: &str) {
5066        let mut writer = CellGeneSparseWriter::new(path, true, 1, 3, 1e4).expect("writer opens");
5067        writer
5068            .write_cell_chunk(cell_chunk(&[1, 2], &[0, 1], 0))
5069            .expect("write");
5070        writer.finalise().expect("finalise");
5071    }
5072
5073    /// Regression: `ParallelSparseReader::new` rejects any file whose version is
5074    /// not a supported version, and nothing exercised that guard.
5075    #[test]
5076    fn test_reader_rejects_a_version_mismatch() {
5077        let temp = TempBin::new("version_mismatch");
5078        write_valid_file(temp.path());
5079        patch_file_header(temp.path(), |h| h.version = SC_FILE_VERSION + 1);
5080
5081        match ParallelSparseReader::new(temp.path()) {
5082            Err(ScDataErrors::FileVersionMismatch { expected, found }) => {
5083                assert_eq!(expected, SC_FILE_VERSION);
5084                assert_eq!(found, SC_FILE_VERSION + 1);
5085            }
5086            Err(other) => panic!("expected a version mismatch, got {other:?}"),
5087            Ok(_) => panic!("a mismatched version must not open"),
5088        }
5089    }
5090
5091    /// A file that is not ours at all must be refused rather than parsed.
5092    #[test]
5093    fn test_reader_rejects_a_bad_magic() {
5094        let temp = TempBin::new("bad_magic");
5095        write_valid_file(temp.path());
5096        patch_file_header(temp.path(), |h| h.magic = *b"NOTOURS!");
5097
5098        assert!(
5099            ParallelSparseReader::new(temp.path()).is_err(),
5100            "a wrong magic string must not open"
5101        );
5102    }
5103
5104    /// A header pointing past the end of the file must error rather than read
5105    /// out of bounds off the mmap.
5106    #[test]
5107    fn test_reader_rejects_a_main_header_offset_past_eof() {
5108        let temp = TempBin::new("offset_past_eof");
5109        write_valid_file(temp.path());
5110        patch_file_header(temp.path(), |h| h.main_header_offset = u64::MAX / 2);
5111
5112        assert!(
5113            ParallelSparseReader::new(temp.path()).is_err(),
5114            "an out-of-range main header offset must not open"
5115        );
5116    }
5117
5118    /// A file truncated mid-payload must error rather than panic.
5119    #[test]
5120    fn test_reader_rejects_a_truncated_file() {
5121        let temp = TempBin::new("truncated");
5122        write_valid_file(temp.path());
5123
5124        let bytes = std::fs::read(temp.path()).expect("read back");
5125        std::fs::write(temp.path(), &bytes[..bytes.len() / 2]).expect("truncate");
5126
5127        assert!(
5128            ParallelSparseReader::new(temp.path()).is_err(),
5129            "a truncated file must not open"
5130        );
5131    }
5132
5133    /// The gene-based half of the same pin, with one chunk forced to u32 raw.
5134    #[test]
5135    fn test_gene_based_file_round_trip() {
5136        let temp = TempBin::new("gene_round_trip");
5137
5138        let mut writer =
5139            CellGeneSparseWriter::new(temp.path(), false, 4, 2, 1e5).expect("writer opens");
5140        writer
5141            .write_gene_chunk(gene_chunk(&[9, 8], &[0, 3], 0))
5142            .expect("write");
5143        writer
5144            .write_gene_chunk(gene_chunk(&[100_000], &[2], 1))
5145            .expect("write");
5146        writer.finalise().expect("finalise");
5147
5148        let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
5149        assert_eq!(reader.target_size(), Some(1e5));
5150        assert!(reader.is_gene_based());
5151
5152        let back = reader.read_gene_parallel(&[0, 1]).expect("read");
5153        assert_eq!(back[0].data_raw.iter().collect::<Vec<_>>(), vec![9, 8]);
5154        assert_eq!(back[1].data_raw.iter().collect::<Vec<_>>(), vec![100_000]);
5155    }
5156
5157    /// Regression: the writer used to `assert!` on the orientation.
5158    #[test]
5159    fn test_writer_rejects_wrong_chunk_orientation() {
5160        let temp = TempBin::new("mode_mismatch");
5161        let mut writer =
5162            CellGeneSparseWriter::new(temp.path(), true, 1, 1, 0.0).expect("writer opens");
5163
5164        match writer.write_gene_chunk(gene_chunk(&[1], &[0], 0)) {
5165            Err(ScDataErrors::ReaderModeMismatch { actual, requested }) => {
5166                assert_eq!(actual, "cell-based");
5167                assert_eq!(requested, "gene-based");
5168            }
5169            other => panic!("expected ReaderModeMismatch, got {other:?}"),
5170        }
5171    }
5172
5173    /// A target size of 0 means raw only: reader and peek must both say `None`.
5174    #[test]
5175    fn test_raw_only_file_reports_no_target_size() {
5176        let temp = TempBin::new("no_target_size");
5177        let mut writer =
5178            CellGeneSparseWriter::new(temp.path(), true, 1, 2, 0.0).expect("writer opens");
5179        writer
5180            .write_cell_chunk(cell_chunk(&[1, 2], &[0, 1], 0))
5181            .expect("write");
5182        writer.finalise().expect("finalise");
5183
5184        let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
5185        assert_eq!(reader.target_size(), None);
5186        assert_eq!(peek_target_size(temp.path()).expect("peek"), None);
5187    }
5188
5189    /////////////////
5190    // File header //
5191    /////////////////
5192
5193    /// The v3 header layout before `target_size` was carved out of the
5194    /// reserved block. Used to prove the swap is byte-neutral in both
5195    /// directions.
5196    #[derive(Encode, Decode, Serialize, Deserialize)]
5197    struct LegacyFileHeader {
5198        /// Magic string as bytes to recognise the file
5199        magic: [u8; 8],
5200        /// Version of the file
5201        version: u32,
5202        /// Offset of the main header
5203        main_header_offset: u64,
5204        /// Is cell-based (CSR format)
5205        cell_based: bool,
5206        /// 32 reserved bytes, all zero in files written by the old writer
5207        _reserved_1: [u8; 32],
5208        /// 3 further reserved bytes
5209        _reserved_2: [u8; 3],
5210    }
5211
5212    /// Encode a header into the fixed, zero-padded 64-byte slot.
5213    ///
5214    /// ### Params
5215    ///
5216    /// * `header` - Anything the bincode standard config can encode.
5217    ///
5218    /// ### Returns
5219    ///
5220    /// Exactly 64 bytes.
5221    fn encode_slot<T: Serialize>(header: &T) -> [u8; 64] {
5222        let encoded = encode_to_vec(header, config::standard()).expect("encode");
5223        assert!(encoded.len() <= 64, "header must fit the fixed slot");
5224        let mut slot = [0_u8; 64];
5225        slot[..encoded.len()].copy_from_slice(&encoded);
5226        slot
5227    }
5228
5229    /// Backwards: pre-`target_size` header bytes must still decode, reading 0.
5230    #[test]
5231    fn test_legacy_header_decodes_with_zero_target_size() {
5232        let legacy = LegacyFileHeader {
5233            magic: *b"SCRNASEQ",
5234            version: SC_FILE_VERSION_V3,
5235            main_header_offset: 123_456,
5236            cell_based: true,
5237            _reserved_1: [0; 32],
5238            _reserved_2: [0; 3],
5239        };
5240
5241        let slot = encode_slot(&legacy);
5242        let (decoded, _) =
5243            decode_from_slice::<FileHeader, _>(&slot, config::standard()).expect("decode as v3");
5244
5245        assert_eq!(decoded.magic, *b"SCRNASEQ");
5246        assert_eq!(decoded.version, SC_FILE_VERSION_V3);
5247        assert_eq!(decoded.main_header_offset, 123_456);
5248        assert!(decoded.cell_based);
5249        assert_eq!(decoded.target_size, 0.0);
5250    }
5251
5252    /// Forwards: today's header must still decode under the old layout.
5253    #[test]
5254    fn test_new_header_still_decodes_on_the_old_layout() {
5255        let mut header = FileHeader::new(false, 1e4);
5256        header.main_header_offset = 987_654_321;
5257
5258        let slot = encode_slot(&header);
5259        let (decoded, _) = decode_from_slice::<LegacyFileHeader, _>(&slot, config::standard())
5260            .expect("decode as legacy");
5261
5262        assert_eq!(decoded.magic, *b"SCRNASEQ");
5263        assert_eq!(decoded.version, SC_FILE_VERSION);
5264        assert_eq!(decoded.main_header_offset, 987_654_321);
5265        assert!(!decoded.cell_based);
5266    }
5267
5268    /// The new field survives the fixed, zero-padded 64-byte slot.
5269    #[test]
5270    fn test_target_size_survives_header_round_trip() {
5271        let slot = encode_slot(&FileHeader::new(true, 1e5));
5272        let (decoded, _) =
5273            decode_from_slice::<FileHeader, _>(&slot, config::standard()).expect("decode");
5274        assert_eq!(decoded.target_size, 1e5);
5275    }
5276
5277    ////////////////////////
5278    // Library-size index //
5279    ////////////////////////
5280
5281    /// The `FileHeader` layout before `library_index_offset` was carved out
5282    /// of the reserved block (28 reserved bytes, all zero when written).
5283    #[derive(Encode, Decode, Serialize, Deserialize)]
5284    struct TargetSizeFileHeader {
5285        magic: [u8; 8],
5286        version: u32,
5287        main_header_offset: u64,
5288        cell_based: bool,
5289        target_size: f32,
5290        _reserved_1: [u8; 28],
5291        _reserved_2: [u8; 3],
5292    }
5293
5294    /// Backwards: header bytes from before the index decode with offset 0,
5295    /// which readers treat as "no index".
5296    #[test]
5297    fn test_pre_index_header_decodes_with_no_library_index() {
5298        let old = TargetSizeFileHeader {
5299            magic: *b"SCRNASEQ",
5300            version: SC_FILE_VERSION_V3,
5301            main_header_offset: 123_456,
5302            cell_based: true,
5303            target_size: 1e4,
5304            _reserved_1: [0; 28],
5305            _reserved_2: [0; 3],
5306        };
5307
5308        let slot = encode_slot(&old);
5309        let (decoded, _) =
5310            decode_from_slice::<FileHeader, _>(&slot, config::standard()).expect("decode");
5311
5312        assert_eq!(decoded.main_header_offset, 123_456);
5313        assert_eq!(decoded.target_size, 1e4);
5314        assert_eq!(decoded.library_index_offset, 0);
5315    }
5316
5317    /// Forwards: a header carrying the index still decodes under the layout
5318    /// before it, so readers built before this change open new files.
5319    #[test]
5320    fn test_header_with_library_index_decodes_on_the_pre_index_layout() {
5321        let mut header = FileHeader::new(true, 1e4);
5322        header.main_header_offset = 987_654_321;
5323        header.library_index_offset = u64::MAX;
5324
5325        let slot = encode_slot(&header);
5326        let (decoded, _) = decode_from_slice::<TargetSizeFileHeader, _>(&slot, config::standard())
5327            .expect("decode as pre-index");
5328
5329        assert_eq!(decoded.magic, *b"SCRNASEQ");
5330        assert_eq!(decoded.version, SC_FILE_VERSION);
5331        assert_eq!(decoded.main_header_offset, 987_654_321);
5332        assert!(decoded.cell_based);
5333        assert_eq!(decoded.target_size, 1e4);
5334    }
5335
5336    /// Read the decoded file header of a written file.
5337    ///
5338    /// ### Params
5339    ///
5340    /// * `path` - Path of the file.
5341    ///
5342    /// ### Returns
5343    ///
5344    /// The `FileHeader`.
5345    fn read_file_header(path: &str) -> FileHeader {
5346        let bytes = std::fs::read(path).expect("read back");
5347        decode_from_slice::<FileHeader, _>(&bytes[0..64], config::standard())
5348            .expect("header decodes")
5349            .0
5350    }
5351
5352    /// Write three cells, out of index order and one with a u32 count,
5353    /// through the per-chunk or the pre-compressed write path.
5354    fn write_index_test_file(path: &str, compressed: bool) {
5355        let chunks = vec![
5356            cell_chunk(&[5, 70_000], &[0, 2], 2),
5357            cell_chunk(&[1, 2, 3], &[0, 1, 2], 0),
5358            cell_chunk(&[9], &[1], 1),
5359        ];
5360        let mut writer = CellGeneSparseWriter::new(path, true, 3, 3, 1e4).expect("writer opens");
5361        if compressed {
5362            write_cell_chunks_parallel(&chunks, &mut writer).expect("write");
5363        } else {
5364            for chunk in chunks {
5365                writer.write_cell_chunk(chunk).expect("write");
5366            }
5367        }
5368        writer.finalise().expect("finalise");
5369    }
5370
5371    /// Library sizes for `indices` as the reader answers them, and as the
5372    /// chunk headers store them.
5373    fn library_sizes_both_ways(
5374        reader: &ParallelSparseReader,
5375        indices: &[usize],
5376    ) -> (Vec<usize>, Vec<usize>) {
5377        let answered = reader.read_cell_library_sizes(indices).expect("read");
5378        let stored = reader
5379            .read_cells_parallel(indices)
5380            .expect("read chunks")
5381            .iter()
5382            .map(|chunk| chunk.library_size)
5383            .collect();
5384        (answered, stored)
5385    }
5386
5387    /// Both write paths record an index that matches the chunk headers, in
5388    /// the order asked.
5389    #[test]
5390    fn test_library_index_matches_chunk_headers() {
5391        for compressed in [false, true] {
5392            let temp = TempBin::new(&format!("library_index_{compressed}"));
5393            write_index_test_file(temp.path(), compressed);
5394
5395            let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
5396            assert!(reader.library_index.is_some());
5397            let (answered, stored) = library_sizes_both_ways(&reader, &[1, 2, 0, 2]);
5398            assert_eq!(answered, vec![9, 70_005, 6, 70_005]);
5399            assert_eq!(answered, stored);
5400        }
5401    }
5402
5403    /// Files without an index (offset 0) fall back to the chunk headers.
5404    #[test]
5405    fn test_library_sizes_fall_back_without_an_index() {
5406        let temp = TempBin::new("library_index_absent");
5407        write_index_test_file(temp.path(), false);
5408        patch_file_header(temp.path(), |h| h.library_index_offset = 0);
5409
5410        let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
5411        assert!(reader.library_index.is_none());
5412        let (answered, _) = library_sizes_both_ways(&reader, &[0, 1, 2]);
5413        assert_eq!(answered, vec![6, 9, 70_005]);
5414    }
5415
5416    /// An index offset past the end of the file must error, not panic.
5417    #[test]
5418    fn test_reader_rejects_a_library_index_past_eof() {
5419        let temp = TempBin::new("library_index_past_eof");
5420        write_valid_file(temp.path());
5421        let len = std::fs::metadata(temp.path()).expect("stat").len();
5422        patch_file_header(temp.path(), |h| h.library_index_offset = len);
5423
5424        assert!(matches!(
5425            ParallelSparseReader::new(temp.path()),
5426            Err(ScDataErrors::HeaderDecodeFailed)
5427        ));
5428    }
5429
5430    /// An index whose count disagrees with the chunk count must error.
5431    #[test]
5432    fn test_reader_rejects_a_library_index_with_the_wrong_count() {
5433        let temp = TempBin::new("library_index_wrong_count");
5434        write_valid_file(temp.path());
5435        // The main header starts with its own byte length, not the chunk count.
5436        let main_header_offset = read_file_header(temp.path()).main_header_offset;
5437        patch_file_header(temp.path(), |h| h.library_index_offset = main_header_offset);
5438
5439        assert!(matches!(
5440            ParallelSparseReader::new(temp.path()),
5441            Err(ScDataErrors::HeaderDecodeFailed)
5442        ));
5443    }
5444
5445    /// The ingest path (mtx, h5ad, 10x) writes through `write_cell_rows`:
5446    /// the index must match the chunk headers and the sizes it reports.
5447    #[test]
5448    fn test_library_index_on_the_ingest_write_path() {
5449        let temp = TempBin::new("library_index_ingest");
5450        let mut rows: Vec<Vec<(u32, u32)>> = (0..300_u32)
5451            .map(|c| {
5452                (0..20_u32)
5453                    .filter(|g| (c * 7 + g * 3) % 5 == 0)
5454                    .map(|g| (g, 1 + (c + g) % 4 + if c == 7 { 70_000 } else { 0 }))
5455                    .collect()
5456            })
5457            .collect();
5458        let mut writer =
5459            CellGeneSparseWriter::new(temp.path(), true, 300, 20, 1e4).expect("writer opens");
5460        let (_, reported) = write_cell_rows(&mut rows, 0, 1e4, &mut writer).expect("write");
5461        writer.finalise().expect("finalise");
5462
5463        let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
5464        assert!(reader.library_index.is_some());
5465        let (answered, stored) = library_sizes_both_ways(&reader, &(0..300).collect::<Vec<_>>());
5466        assert_eq!(answered, stored);
5467        assert_eq!(answered, reported);
5468    }
5469
5470    /// Gene-based files carry no library-size index.
5471    #[test]
5472    fn test_gene_based_file_has_no_library_index() {
5473        let temp = TempBin::new("library_index_gene_file");
5474        let mut writer =
5475            CellGeneSparseWriter::new(temp.path(), false, 2, 1, 1e4).expect("writer opens");
5476        writer
5477            .write_gene_chunk(gene_chunk(&[1, 2], &[0, 1], 0))
5478            .expect("write");
5479        writer.finalise().expect("finalise");
5480
5481        assert_eq!(read_file_header(temp.path()).library_index_offset, 0);
5482        let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
5483        assert!(reader.library_index.is_none());
5484    }
5485}