Skip to main content

bixverse_sc_data/
mtx_io.rs

1//! Matrix Market (.mtx) ingestion into the bixverse cell store.
2//!
3//! The text is parsed once. Plain files are cut into newline-aligned blocks
4//! read with positional reads; gzip input (found by its magic bytes,
5//! multi-member included) cannot be split, so one thread inflates it and
6//! feeds blocks through a bounded channel to the parallel parsers. That pass
7//! counts gene nnz, totals every cell and spills each entry into binary bucket
8//! files keyed by the original cell. The cell QC is resolved from those totals
9//! without a second pass (see [`TextPassAcc`]), and the write reads the
10//! buckets back. Input already in cell order skips the per-bucket sort.
11//!
12//! Values are raw counts. `integer` and `real` fields must hold non-negative
13//! integral values (a real `1.5` is an error, not a silent `1`); `pattern`
14//! entries count 1. `complex` fields and any symmetry other than `general`
15//! are rejected.
16
17use flate2::read::MultiGzDecoder;
18use rayon::prelude::*;
19use std::fs::File;
20use std::io::{BufRead, BufReader, BufWriter, Read, Seek, SeekFrom, Write};
21use std::path::{Path, PathBuf};
22use std::sync::Mutex;
23use std::sync::atomic::{AtomicUsize, Ordering};
24use std::sync::mpsc::sync_channel;
25use std::time::Instant;
26use thousands::Separable;
27
28use crate::data_io::{
29    CellGeneSparseWriter, CellOnFileQuality, INDEX_DROPPED, compress_cell_row, dense_index_map,
30};
31use crate::prelude::*;
32
33///////////////
34// Constants //
35///////////////
36
37/// Byte width of one record in a temporary bucket spill file:
38/// `cell: u32`, `gene: u32`, `count: u32`, original 0-based indices.
39///
40/// These files live and die inside one [`MtxReader`], so the layout carries
41/// no on-disk compatibility obligation.
42const BUCKET_RECORD_LEN: usize = 12;
43
44/// Bytes of mtx text per block, the unit of parallel parsing. Every worker
45/// holds one block, and the gzip channel holds up to
46/// [`GZ_BLOCKS_PER_THREAD`] per worker, so this times the thread count bounds
47/// the text in flight.
48const MTX_BLOCK_BYTES: usize = 8 * 1024 * 1024;
49
50/// Blocks the gzip reader may run ahead of the parsers, per worker. Enough to
51/// keep the parsers fed across the jitter of a single inflate thread.
52const GZ_BLOCKS_PER_THREAD: usize = 2;
53
54/// Size at which a worker's per-bucket record buffer is appended to the
55/// shared bucket file. Caps the buffers at
56/// `n_threads * n_buckets * BUCKET_FLUSH_BYTES`.
57const BUCKET_FLUSH_BYTES: usize = 64 * 1024;
58
59/// Most bucket files open at once, well below the macOS fd limit.
60const MAX_BUCKETS: usize = 128;
61
62/// Original cells per bucket before [`MAX_BUCKETS`] caps the count. Keeps a
63/// bucket, read back whole in the write pass, small next to the matrix.
64const CELLS_PER_BUCKET: usize = 5_000;
65
66/// Relative tolerance for a `real` value to count as integral. Writers that
67/// print floats round-trip an integer exactly; anything further off is not a
68/// raw count.
69const REAL_INTEGRAL_TOL: f64 = 1e-6;
70
71/// The two magic bytes that open every gzip member.
72const GZ_MAGIC: [u8; 2] = [0x1f, 0x8b];
73
74/// Distinguishes the spill files of concurrent readers in one process.
75static SPILL_COUNTER: AtomicUsize = AtomicUsize::new(0);
76
77////////////
78// Header //
79////////////
80
81/// Value type of an mtx file, from the `%%MatrixMarket` banner.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum MtxField {
84    /// Integer values, also assumed when the banner is missing.
85    Integer,
86    /// Floating point values; each must be a non-negative integral count.
87    Real,
88    /// No value column; every entry counts 1.
89    Pattern,
90}
91
92/// MTX file metadata
93#[derive(Debug, Clone)]
94pub struct MtxHeader {
95    /// Number of cells identified in the .mtx header.
96    pub total_cells: usize,
97    /// Number of genes identified in the .mtx header.
98    pub total_genes: usize,
99    /// Number of entries identified in the .mtx header.
100    pub total_entries: usize,
101    /// Value type from the banner.
102    pub field: MtxField,
103}
104
105/// Parse the `%%MatrixMarket` banner.
106///
107/// ### Params
108///
109/// * `line` - The banner line, newline included or not
110///
111/// ### Returns
112///
113/// The value type, or an error for anything other than a general coordinate
114/// matrix of integer, real or pattern values.
115fn parse_banner(line: &[u8]) -> Result<MtxField, ScDataErrors> {
116    let text = String::from_utf8_lossy(line).to_ascii_lowercase();
117    let tokens: Vec<&str> = text.split_whitespace().collect();
118    let [_, object, format, field, symmetry] = tokens[..] else {
119        return Err(ScDataErrors::MtxHeaderInvalid(
120            "the banner needs object, format, field and symmetry",
121        ));
122    };
123    if object != "matrix" {
124        return Err(ScDataErrors::MtxUnsupported(format!("object '{object}'")));
125    }
126    if format != "coordinate" {
127        return Err(ScDataErrors::MtxUnsupported(format!(
128            "format '{format}', only sparse 'coordinate' is read"
129        )));
130    }
131    if symmetry != "general" {
132        return Err(ScDataErrors::MtxUnsupported(format!(
133            "symmetry '{symmetry}', only 'general' is read"
134        )));
135    }
136    match field {
137        "integer" => Ok(MtxField::Integer),
138        "real" | "double" => Ok(MtxField::Real),
139        "pattern" => Ok(MtxField::Pattern),
140        other => Err(ScDataErrors::MtxUnsupported(format!(
141            "field '{other}', counts must be integer, real or pattern"
142        ))),
143    }
144}
145
146/// Read the banner, comments and shape line.
147///
148/// ### Params
149///
150/// * `reader` - Stream positioned at the start of the (decompressed) file
151/// * `cells_as_rows` - Orientation, names the failing field in errors
152///
153/// ### Returns
154///
155/// `(field, [rows, cols, entries], bytes consumed)`.
156fn parse_preamble(
157    reader: &mut impl BufRead,
158    cells_as_rows: bool,
159) -> Result<(MtxField, [usize; 3], u64), ScDataErrors> {
160    let mut line = Vec::new();
161    let mut consumed = 0u64;
162    let mut field = MtxField::Integer;
163    let mut first = true;
164    loop {
165        line.clear();
166        let n = reader.read_until(b'\n', &mut line)?;
167        if n == 0 {
168            return Err(ScDataErrors::MtxHeaderInvalid("no shape line"));
169        }
170        consumed += n as u64;
171        if first && line.starts_with(b"%%") {
172            field = parse_banner(&line)?;
173        }
174        first = false;
175        if line.first() != Some(&b'%') {
176            break;
177        }
178    }
179
180    let text = std::str::from_utf8(&line)
181        .map_err(|_| ScDataErrors::MtxHeaderInvalid("the shape line is not text"))?;
182    let parts: Vec<&str> = text.split_whitespace().collect();
183    if parts.len() != 3 {
184        return Err(ScDataErrors::MtxHeaderInvalid(
185            "expected three whitespace-separated fields on the shape line",
186        ));
187    }
188    let names = if cells_as_rows {
189        ["cell count", "gene count", "entry count"]
190    } else {
191        ["gene count", "cell count", "entry count"]
192    };
193    let mut shape = [0usize; 3];
194    for (k, (s, field)) in parts.iter().zip(names).enumerate() {
195        shape[k] = s
196            .parse()
197            .map_err(|_| ScDataErrors::MtxParseError { field })?;
198    }
199    Ok((field, shape, consumed))
200}
201
202////////////
203// Source //
204////////////
205
206/// An mtx file, plain or gzipped, read as blocks of whole lines.
207pub(crate) struct MtxSource {
208    /// Path to the file
209    path: PathBuf,
210    /// Whether the file is gzip, by magic bytes
211    gz: bool,
212    /// Value type from the banner
213    pub(crate) field: MtxField,
214    /// `[rows, cols, entries]` from the shape line
215    pub(crate) shape: [usize; 3],
216    /// Offset of the first entry line in the (decompressed) stream
217    data_start: u64,
218    /// Target block size, [`MTX_BLOCK_BYTES`] outside of tests
219    block_bytes: usize,
220}
221
222impl MtxSource {
223    /// Open an mtx file and read its header.
224    ///
225    /// ### Params
226    ///
227    /// * `path` - Plain or gzipped mtx
228    /// * `cells_as_rows` - Orientation, names the failing field in errors
229    ///
230    /// ### Returns
231    ///
232    /// The source, ready to be read block by block.
233    pub(crate) fn open(path: &Path, cells_as_rows: bool) -> Result<Self, ScDataErrors> {
234        let mut magic = [0u8; 2];
235        let mut file = File::open(path)?;
236        let gz = file.read(&mut magic)? == 2 && magic == GZ_MAGIC;
237        file.rewind()?;
238        let mut source = Self {
239            path: path.to_path_buf(),
240            gz,
241            field: MtxField::Integer,
242            shape: [0; 3],
243            data_start: 0,
244            block_bytes: MTX_BLOCK_BYTES,
245        };
246        let mut reader = BufReader::new(source.stream()?);
247        let (field, shape, data_start) = parse_preamble(&mut reader, cells_as_rows)?;
248        source.field = field;
249        source.shape = shape;
250        source.data_start = data_start;
251        Ok(source)
252    }
253
254    /// The file as a byte stream from its start, inflated if gzipped.
255    ///
256    /// ### Returns
257    ///
258    /// A reader over the (decompressed) text.
259    fn stream(&self) -> Result<Box<dyn Read + Send>, ScDataErrors> {
260        let file = File::open(&self.path)?;
261        Ok(if self.gz {
262            Box::new(MultiGzDecoder::new(file))
263        } else {
264            Box::new(file)
265        })
266    }
267
268    /// Hand every block of whole entry lines to `f`, in file order, on the
269    /// calling thread.
270    ///
271    /// ### Params
272    ///
273    /// * `f` - Called with the block's sequence number and its bytes
274    ///
275    /// ### Returns
276    ///
277    /// The first error of the read or of `f`.
278    pub(crate) fn for_each_block_seq(
279        &self,
280        mut f: impl FnMut(usize, Vec<u8>) -> Result<(), ScDataErrors>,
281    ) -> Result<(), ScDataErrors> {
282        let mut reader = self.stream()?;
283        std::io::copy(
284            &mut (&mut reader).take(self.data_start),
285            &mut std::io::sink(),
286        )?;
287
288        let mut carry = Vec::new();
289        let mut seq = 0;
290        loop {
291            let mut block = std::mem::take(&mut carry);
292            block.reserve(self.block_bytes);
293            let n = (&mut reader)
294                .take(self.block_bytes as u64)
295                .read_to_end(&mut block)?;
296            if n == 0 {
297                if !block.is_empty() {
298                    f(seq, block)?;
299                }
300                return Ok(());
301            }
302            // a line longer than a block stays in the carry until it ends
303            if let Some(p) = block.iter().rposition(|&b| b == b'\n') {
304                carry = block[p + 1..].to_vec();
305                block.truncate(p + 1);
306                f(seq, block)?;
307                seq += 1;
308            } else {
309                carry = block;
310            }
311        }
312    }
313
314    /// Newline-aligned byte ranges of a plain file's entry lines.
315    ///
316    /// ### Returns
317    ///
318    /// Half-open ranges of about `block_bytes`, covering every line once.
319    fn plain_ranges(&self) -> Result<Vec<(u64, u64)>, ScDataErrors> {
320        let mut file = File::open(&self.path)?;
321        let len = file.metadata()?.len();
322        let mut ranges = Vec::new();
323        let mut a = self.data_start;
324        let mut probe = vec![0u8; 64 * 1024];
325        while a < len {
326            let mut b = a + self.block_bytes as u64;
327            if b >= len {
328                ranges.push((a, len));
329                break;
330            }
331            file.seek(SeekFrom::Start(b))?;
332            loop {
333                let k = file.read(&mut probe)?;
334                if k == 0 {
335                    b = len;
336                    break;
337                }
338                if let Some(p) = probe[..k].iter().position(|&c| c == b'\n') {
339                    b += p as u64 + 1;
340                    break;
341                }
342                b += k as u64;
343            }
344            ranges.push((a, b));
345            a = b;
346        }
347        Ok(ranges)
348    }
349
350    /// Fold every block in parallel into one accumulator per worker thread.
351    ///
352    /// Plain files are read as positional block reads by the workers. Gzip is
353    /// inflated on one extra thread that sends blocks through a bounded
354    /// channel. Block order is not preserved; `f` gets each block's sequence
355    /// number to restore it.
356    ///
357    /// ### Params
358    ///
359    /// * `init` - Builds a worker's accumulator on its first block
360    /// * `f` - Folds one block into the worker's accumulator
361    ///
362    /// ### Returns
363    ///
364    /// The accumulators of the workers that saw a block.
365    pub(crate) fn par_fold_blocks<A, I, F>(&self, init: I, f: F) -> Result<Vec<A>, ScDataErrors>
366    where
367        A: Send,
368        I: Fn() -> A + Sync,
369        F: Fn(&mut A, usize, &[u8]) -> Result<(), ScDataErrors> + Sync,
370    {
371        // One slot per worker. A worker never runs rayon work inside `f`, so
372        // it never waits on its own slot.
373        let slots: Vec<Mutex<Option<A>>> = (0..rayon::current_num_threads())
374            .map(|_| Mutex::new(None))
375            .collect();
376        let fold = |seq: usize, bytes: &[u8]| -> Result<(), ScDataErrors> {
377            let k = rayon::current_thread_index().unwrap_or(0) % slots.len();
378            let mut slot = slots[k]
379                .lock()
380                .expect("an accumulator lock is never held across a panic");
381            f(slot.get_or_insert_with(&init), seq, bytes)
382        };
383
384        if self.gz {
385            let (tx, rx) = sync_channel::<(usize, Vec<u8>)>(slots.len() * GZ_BLOCKS_PER_THREAD);
386            std::thread::scope(|s| {
387                let reader = s.spawn(move || {
388                    self.for_each_block_seq(|seq, block| {
389                        // a closed channel means the parsers stopped on an
390                        // error, which they report
391                        tx.send((seq, block)).map_err(|_| {
392                            ScDataErrors::BinaryIo(std::io::Error::other("mtx parsers stopped"))
393                        })
394                    })
395                });
396                let parsed = rx
397                    .into_iter()
398                    .par_bridge()
399                    .try_for_each(|(seq, block)| fold(seq, &block));
400                let read = reader.join().expect("the gzip reader thread panicked");
401                parsed.and(read)
402            })?;
403        } else {
404            self.plain_ranges()?
405                .par_iter()
406                .enumerate()
407                .try_for_each_init(
408                    Vec::new,
409                    |buf, (seq, &(a, b))| -> Result<(), ScDataErrors> {
410                        let mut file = File::open(&self.path)?;
411                        file.seek(SeekFrom::Start(a))?;
412                        buf.resize((b - a) as usize, 0);
413                        file.read_exact(buf)?;
414                        fold(seq, buf)
415                    },
416                )?;
417        }
418
419        Ok(slots
420            .into_iter()
421            .filter_map(|m| {
422                m.into_inner()
423                    .expect("an accumulator lock is never held across a panic")
424            })
425            .collect())
426    }
427}
428
429/////////////
430// Parsing //
431/////////////
432
433/// Parse an unsigned decimal at `line[*i..]`, advancing `i`.
434///
435/// ### Params
436///
437/// * `line` - Line bytes
438/// * `i` - Cursor
439///
440/// ### Returns
441///
442/// The value (wrapping past `u32::MAX`, which no mtx index reaches), `None`
443/// if no digit is at the cursor.
444#[inline]
445fn parse_index(line: &[u8], i: &mut usize) -> Option<u32> {
446    let start = *i;
447    let mut v = 0u32;
448    while *i < line.len() && line[*i].is_ascii_digit() {
449        v = v.wrapping_mul(10).wrapping_add((line[*i] - b'0') as u32);
450        *i += 1;
451    }
452    (*i > start).then_some(v)
453}
454
455/// Advance `i` past spaces and tabs.
456///
457/// ### Params
458///
459/// * `line` - Line bytes
460/// * `i` - Cursor
461#[inline]
462fn skip_blank(line: &[u8], i: &mut usize) {
463    while *i < line.len() && (line[*i] == b' ' || line[*i] == b'\t') {
464        *i += 1;
465    }
466}
467
468/// Parse a value token as a raw count.
469///
470/// Plain digits take the fast path. Anything else (`2.0`, `1.5e+01`, `-1`)
471/// is read as a float and must be finite, non-negative and integral within
472/// [`REAL_INTEGRAL_TOL`].
473///
474/// ### Params
475///
476/// * `token` - The value field, no surrounding blanks
477///
478/// ### Returns
479///
480/// The count, or [`ScDataErrors::MtxNotACount`].
481#[inline]
482fn parse_count(token: &[u8]) -> Result<u32, ScDataErrors> {
483    let not_a_count = || ScDataErrors::MtxNotACount {
484        value: String::from_utf8_lossy(token).into_owned(),
485    };
486    if !token.is_empty() && token.iter().all(u8::is_ascii_digit) {
487        let mut v = 0u64;
488        for &b in token {
489            v = v * 10 + (b - b'0') as u64;
490            if v > u32::MAX as u64 {
491                return Err(not_a_count());
492            }
493        }
494        return Ok(v as u32);
495    }
496    let v: f64 = std::str::from_utf8(token)
497        .ok()
498        .and_then(|s| s.parse().ok())
499        .ok_or_else(not_a_count)?;
500    let r = v.round();
501    if !v.is_finite()
502        || r < 0.0
503        || r > u32::MAX as f64
504        || (v - r).abs() > REAL_INTEGRAL_TOL * r.abs().max(1.0)
505    {
506        return Err(not_a_count());
507    }
508    Ok(r as u32)
509}
510
511/// Parse one entry line.
512///
513/// ### Params
514///
515/// * `line` - The line without its terminator
516/// * `field` - Value type
517///
518/// ### Returns
519///
520/// `Some((row, col, count))` with 1-based coordinates as stored, `None` for a
521/// line that is not an entry (blank, comment, too few fields), an error for a
522/// value that is not a raw count.
523#[inline]
524fn parse_entry(line: &[u8], field: MtxField) -> Result<Option<(u32, u32, u32)>, ScDataErrors> {
525    let mut i = 0;
526    let Some(row) = parse_index(line, &mut i) else {
527        return Ok(None);
528    };
529    skip_blank(line, &mut i);
530    let Some(col) = parse_index(line, &mut i) else {
531        return Ok(None);
532    };
533    if field == MtxField::Pattern {
534        return Ok(Some((row, col, 1)));
535    }
536    skip_blank(line, &mut i);
537    let start = i;
538    while i < line.len() && !matches!(line[i], b' ' | b'\t') {
539        i += 1;
540    }
541    if i == start {
542        return Ok(None);
543    }
544    Ok(Some((row, col, parse_count(&line[start..i])?)))
545}
546
547/// Parse every entry line of a block.
548///
549/// ### Params
550///
551/// * `bytes` - Whole lines
552/// * `field` - Value type
553/// * `f` - Called with `(row, col, count)` per entry, 1-based as stored
554///
555/// ### Returns
556///
557/// The first value error.
558pub(crate) fn parse_block<F: FnMut(u32, u32, u32)>(
559    bytes: &[u8],
560    field: MtxField,
561    mut f: F,
562) -> Result<(), ScDataErrors> {
563    for line in bytes.split(|&b| b == b'\n') {
564        let line = line.strip_suffix(b"\r").unwrap_or(line);
565        if let Some((row, col, value)) = parse_entry(line, field)? {
566            f(row, col, value);
567        }
568    }
569    Ok(())
570}
571
572///////////
573// Spill //
574///////////
575
576/// RAII guard that removes temporary bucket files on drop.
577///
578/// Ensures temp files are cleaned up even if a pass returns early due to an
579/// I/O error or panic.
580struct TempFileGuard(Vec<PathBuf>);
581
582/// Drop implementation for `TempFileGuard`
583///
584/// Attempts to remove each tracked temp file. Errors are silently ignored since
585/// this runs during unwind and the files may already have been removed by the
586/// writing pass.
587impl Drop for TempFileGuard {
588    fn drop(&mut self) {
589        for p in &self.0 {
590            let _ = std::fs::remove_file(p);
591        }
592    }
593}
594
595/// One bucket file while the text pass appends to it.
596struct BucketFile {
597    /// Buffered handle
598    writer: BufWriter<File>,
599    /// Bytes appended so far
600    len: u64,
601    /// `(block seq, offset, length)` of every appended segment
602    segments: Vec<(usize, u64, u64)>,
603}
604
605/// Cell order seen in one block.
606struct BlockOrder {
607    /// Block sequence number
608    seq: usize,
609    /// First cell of the block
610    first: u32,
611    /// Last cell of the block
612    last: u32,
613    /// Whether cells never decreased inside the block
614    sorted: bool,
615}
616
617/// Per-worker state of the text pass.
618///
619/// QC without a second pass: a gene is dropped when it is seen in fewer than
620/// `min_cells` cells, so while a worker has seen a gene fewer than
621/// `min_cells` times it keeps that gene's `(cell, count)` entries. Once the
622/// count is reached the gene is certainly kept and the list goes. After the
623/// merge, the entries of the dropped genes are exactly the union of the lists
624/// still held, and subtracting them from the all-gene cell totals gives the
625/// totals over kept genes.
626struct TextPassAcc {
627    /// Entries per gene
628    gene_nnz: Vec<u32>,
629    /// `(cell, count)` per gene while the gene is below `min_cells` here
630    rare: Vec<Vec<(u32, u32)>>,
631    /// `(nnz, library size)` per cell over all genes
632    cell_stats: Vec<(u32, u32)>,
633    /// Record buffer per bucket
634    bufs: Vec<Vec<u8>>,
635    /// Cell order of every block this worker parsed
636    blocks: Vec<BlockOrder>,
637}
638
639/// Entries spilled by the text pass, and the QC it resolved.
640struct Spill {
641    /// Bucket files, removed on drop
642    files: TempFileGuard,
643    /// Original cells per bucket
644    cells_per_bucket: usize,
645    /// `(block seq, offset, length)` of every segment, per bucket
646    segments: Vec<Vec<(usize, u64, u64)>>,
647    /// Whether the file listed cells in non-decreasing order
648    cell_major: bool,
649    /// QC from the text pass
650    quality: CellOnFileQuality,
651}
652
653////////////
654// Reader //
655////////////
656
657/// MTX final data
658///
659/// Structure to store final results after reading in the .mtx file
660#[derive(Debug, Clone)]
661pub struct MtxFinalData {
662    /// Structure containing the information on which cells/genes to keep and
663    /// library size and NNZ for cells.
664    pub cell_qc: CellQuality,
665    /// No of genes that were read in
666    pub no_genes: usize,
667    /// No of cells that were read in
668    pub no_cells: usize,
669}
670
671/// MTX Reader for bixverse
672///
673/// Parses the text once, in [`Self::parse_mtx_quality`] or on demand in the
674/// write, and keeps the spilled entries between the two calls, so use one
675/// reader for both.
676pub struct MtxReader {
677    /// The input file
678    source: MtxSource,
679    /// Header of the MtxFile
680    header: MtxHeader,
681    /// Cell and gene quality parameters to apply
682    qc_params: MinCellQuality,
683    /// Are the cells stored as rows
684    cells_as_rows: bool,
685    /// Where the bucket files go
686    temp_dir: PathBuf,
687    /// Spilled entries, after the text pass
688    spill: Option<Spill>,
689}
690
691impl MtxReader {
692    /// Generate a new instance of the reader
693    ///
694    /// The file may be plain or gzipped (detected by its magic bytes). Bucket
695    /// files go to the system temp directory unless [`Self::with_temp_dir`]
696    /// says otherwise; they take 12 bytes per entry.
697    ///
698    /// ### Params
699    ///
700    /// * `path` - Path to the mtx file.
701    /// * `qc_params` - The min quality parameters that genes and cells have to
702    ///   reach and the target library size
703    /// * `cells_as_rows` - Boolean. Are the cells the rows (= true) or columns in
704    ///   the mtx file.
705    pub fn new<P: AsRef<Path>>(
706        path: P,
707        qc_params: MinCellQuality,
708        cells_as_rows: bool,
709    ) -> Result<Self, ScDataErrors> {
710        let source = MtxSource::open(path.as_ref(), cells_as_rows)?;
711        let [rows, cols, entries] = source.shape;
712        let (total_cells, total_genes) = if cells_as_rows {
713            (rows, cols)
714        } else {
715            (cols, rows)
716        };
717        let header = MtxHeader {
718            total_cells,
719            total_genes,
720            total_entries: entries,
721            field: source.field,
722        };
723        Ok(Self {
724            source,
725            header,
726            qc_params,
727            cells_as_rows,
728            temp_dir: std::env::temp_dir(),
729            spill: None,
730        })
731    }
732
733    /// Put the bucket files in `dir` instead of the system temp directory.
734    ///
735    /// ### Params
736    ///
737    /// * `dir` - Directory for the spill, 12 bytes per entry
738    ///
739    /// ### Returns
740    ///
741    /// The reader.
742    pub fn with_temp_dir<P: AsRef<Path>>(mut self, dir: P) -> Self {
743        self.temp_dir = dir.as_ref().to_path_buf();
744        self
745    }
746
747    /// Parse the file and resolve which cells and genes pass QC.
748    ///
749    /// The single text pass: gene nnz for `min_cells`, then per-cell nnz and
750    /// library size over the kept genes for `min_unique_genes` and
751    /// `min_lib_size`. The parsed entries stay spilled on disk for the write.
752    ///
753    /// ### Params
754    ///
755    /// * `verbose` - Controls verbosity of the function.
756    ///
757    /// ### Returns
758    ///
759    /// The CellOnFileQuality file containing the indices and mappings
760    /// for the cells/genes to keep.
761    pub fn parse_mtx_quality(&mut self, verbose: bool) -> Result<CellOnFileQuality, ScDataErrors> {
762        if self.spill.is_none() {
763            self.spill = Some(self.text_pass(verbose)?);
764        }
765        Ok(self
766            .spill
767            .as_ref()
768            .expect("spill set above")
769            .quality
770            .clone())
771    }
772
773    /// The one text pass: parse, count, spill, resolve QC.
774    ///
775    /// ### Params
776    ///
777    /// * `verbose` - Controls verbosity
778    ///
779    /// ### Returns
780    ///
781    /// The spill with its QC.
782    fn text_pass(&self, verbose: bool) -> Result<Spill, ScDataErrors> {
783        let start = Instant::now();
784        let n_cells = self.header.total_cells;
785        let n_genes = self.header.total_genes;
786        let min_cells = self.qc_params.min_cells;
787        let cells_as_rows = self.cells_as_rows;
788        let field = self.source.field;
789
790        let n_buckets = n_cells.div_ceil(CELLS_PER_BUCKET).clamp(1, MAX_BUCKETS);
791        let cells_per_bucket = n_cells.div_ceil(n_buckets).max(1);
792
793        let tag = SPILL_COUNTER.fetch_add(1, Ordering::Relaxed);
794        let paths: Vec<PathBuf> = (0..n_buckets)
795            .map(|i| {
796                self.temp_dir.join(format!(
797                    ".bixverse_mtx_{}_{tag}.bucket_{i:04}.tmp",
798                    std::process::id()
799                ))
800            })
801            .collect();
802        let files = TempFileGuard(paths.clone());
803        let buckets: Vec<Mutex<BucketFile>> = paths
804            .iter()
805            .map(|p| {
806                File::create(p).map(|f| {
807                    Mutex::new(BucketFile {
808                        writer: BufWriter::with_capacity(256 * 1024, f),
809                        len: 0,
810                        segments: Vec::new(),
811                    })
812                })
813            })
814            .collect::<std::io::Result<_>>()?;
815
816        if verbose {
817            let kind = match field {
818                MtxField::Integer => "integer",
819                MtxField::Real => "real",
820                MtxField::Pattern => "pattern",
821            };
822            println!(
823                "Text pass ({}{kind}): {n_buckets} buckets, ~{cells_per_bucket} cells/bucket",
824                if self.source.gz { "gzip, " } else { "" },
825            );
826        }
827
828        let flush = |bucket: usize, seq: usize, buf: &mut Vec<u8>| -> Result<(), ScDataErrors> {
829            let mut b = buckets[bucket]
830                .lock()
831                .expect("bucket writer lock is never held across a panic");
832            let len = buf.len() as u64;
833            let offset = b.len;
834            b.writer.write_all(buf)?;
835            b.segments.push((seq, offset, len));
836            b.len += len;
837            buf.clear();
838            Ok(())
839        };
840
841        let accs = self.source.par_fold_blocks(
842            || TextPassAcc {
843                gene_nnz: vec![0; n_genes],
844                rare: vec![Vec::new(); n_genes],
845                cell_stats: vec![(0, 0); n_cells],
846                bufs: vec![Vec::new(); n_buckets],
847                blocks: Vec::new(),
848            },
849            |acc, seq, bytes| {
850                let mut order: Option<BlockOrder> = None;
851                let mut flush_err = Ok(());
852                parse_block(bytes, field, |row, col, value| {
853                    let (cell, gene) = if cells_as_rows {
854                        (row, col)
855                    } else {
856                        (col, row)
857                    };
858                    let Some(g) = (gene as usize).checked_sub(1).filter(|&g| g < n_genes) else {
859                        return;
860                    };
861                    acc.gene_nnz[g] += 1;
862                    let Some(c) = (cell as usize).checked_sub(1).filter(|&c| c < n_cells) else {
863                        return;
864                    };
865                    let seen = acc.gene_nnz[g] as usize;
866                    if seen < min_cells {
867                        acc.rare[g].push((c as u32, value));
868                    } else if seen == min_cells {
869                        acc.rare[g] = Vec::new();
870                    }
871                    let stats = &mut acc.cell_stats[c];
872                    stats.0 += 1;
873                    stats.1 += value;
874
875                    let c32 = c as u32;
876                    match &mut order {
877                        None => {
878                            order = Some(BlockOrder {
879                                seq,
880                                first: c32,
881                                last: c32,
882                                sorted: true,
883                            })
884                        }
885                        Some(o) => {
886                            o.sorted &= c32 >= o.last;
887                            o.last = c32;
888                        }
889                    }
890
891                    let bucket = c / cells_per_bucket;
892                    let buf = &mut acc.bufs[bucket];
893                    buf.extend_from_slice(&c32.to_le_bytes());
894                    buf.extend_from_slice(&(g as u32).to_le_bytes());
895                    buf.extend_from_slice(&value.to_le_bytes());
896                    if buf.len() >= BUCKET_FLUSH_BYTES && flush_err.is_ok() {
897                        flush_err = flush(bucket, seq, buf);
898                    }
899                })?;
900                flush_err?;
901                // every segment must belong to one block, so the cell order
902                // can be restored from the block sequence
903                for (bucket, buf) in acc.bufs.iter_mut().enumerate() {
904                    if !buf.is_empty() {
905                        flush(bucket, seq, buf)?;
906                    }
907                }
908                acc.blocks.extend(order);
909                Ok(())
910            },
911        )?;
912
913        let mut segments = Vec::with_capacity(n_buckets);
914        for bucket in buckets {
915            let mut b = bucket
916                .into_inner()
917                .expect("bucket writer lock is never held across a panic");
918            b.writer.flush()?;
919            segments.push(b.segments);
920        }
921
922        // merge the workers
923        let mut gene_nnz = vec![0usize; n_genes];
924        let mut cell_stats = vec![(0u32, 0u32); n_cells];
925        let mut blocks = Vec::new();
926        for acc in &accs {
927            gene_nnz
928                .iter_mut()
929                .zip(&acc.gene_nnz)
930                .for_each(|(t, &n)| *t += n as usize);
931            cell_stats
932                .par_iter_mut()
933                .zip(&acc.cell_stats)
934                .for_each(|(t, s)| {
935                    t.0 += s.0;
936                    t.1 += s.1;
937                });
938        }
939        let genes_to_keep: Vec<usize> =
940            (0..n_genes).filter(|&g| gene_nnz[g] >= min_cells).collect();
941        for mut acc in accs {
942            for (g, rare) in acc.rare.iter().enumerate() {
943                if gene_nnz[g] < min_cells {
944                    for &(c, v) in rare {
945                        let s = &mut cell_stats[c as usize];
946                        s.0 -= 1;
947                        s.1 -= v;
948                    }
949                }
950            }
951            blocks.append(&mut acc.blocks);
952        }
953
954        blocks.sort_unstable_by_key(|b| b.seq);
955        let cell_major =
956            blocks.iter().all(|b| b.sorted) && blocks.windows(2).all(|w| w[0].last <= w[1].first);
957
958        let cells_to_keep: Vec<usize> = (0..n_cells)
959            .filter(|&c| {
960                cell_stats[c].0 as usize >= self.qc_params.min_unique_genes
961                    && cell_stats[c].1 as f32 >= self.qc_params.min_lib_size as f32
962            })
963            .collect();
964
965        let mut quality = CellOnFileQuality::new(cells_to_keep, genes_to_keep);
966        quality.generate_maps_sets();
967
968        if verbose {
969            println!(
970                "Text pass done: {:.2?} ({} order)",
971                start.elapsed(),
972                if cell_major { "cell-major" } else { "unsorted" }
973            );
974            println!(
975                "Genes passing QC: {} / {}",
976                quality.genes_to_keep.len().separate_with_underscores(),
977                n_genes.separate_with_underscores()
978            );
979            println!(
980                "Cells passing QC: {} / {}",
981                quality.cells_to_keep.len().separate_with_underscores(),
982                n_cells.separate_with_underscores()
983            );
984        }
985
986        Ok(Spill {
987            files,
988            cells_per_bucket,
989            segments,
990            cell_major,
991            quality,
992        })
993    }
994
995    /// Process the mtx file and write to binarised Rust file
996    ///
997    /// Calls [`Self::process_mtx_and_write_bin_streaming`]; the bucketed write
998    /// is the only one left, so both entry points give the same store.
999    ///
1000    /// ### Params
1001    ///
1002    /// * `bin_path` - Where to save the binarised file.
1003    /// * `quality` - Structure indicating which cells and genes to keep from
1004    ///   the mtx file.
1005    /// * `verbose` - Controls verbosity of the function.
1006    ///
1007    /// ### Returns
1008    ///
1009    /// The `MtxFinalData` with information how many cells were written to
1010    /// file, how many genes were included in which cells did not parse
1011    /// the thresholds.
1012    pub fn process_mtx_and_write_bin(
1013        self,
1014        bin_path: &str,
1015        quality: &CellOnFileQuality,
1016        verbose: bool,
1017    ) -> Result<MtxFinalData, ScDataErrors> {
1018        self.process_mtx_and_write_bin_streaming(bin_path, quality, verbose)
1019    }
1020
1021    /// Process the mtx file and write to binarised Rust file
1022    ///
1023    /// Reads the bucket files of the text pass (running it first if
1024    /// [`Self::parse_mtx_quality`] was not called) one at a time, so memory is
1025    /// bounded by a bucket. A file listed in cell order needs no sort; any
1026    /// other order sorts each bucket on the full `(cell, gene, count)` tuple,
1027    /// which makes the output deterministic. Every kept cell is written,
1028    /// empty or not.
1029    ///
1030    /// ### Params
1031    ///
1032    /// * `bin_path` - Where to save the binarised file.
1033    /// * `quality` - Structure indicating which cells and genes to keep from
1034    ///   the mtx file. `cells_to_keep` must be ascending.
1035    /// * `verbose` - Controls verbosity of the function.
1036    ///
1037    /// ### Returns
1038    ///
1039    /// The `MtxFinalData` with information how many cells were written to
1040    /// file, how many genes were included and the per-cell library size and
1041    /// NNZ statistics.
1042    pub fn process_mtx_and_write_bin_streaming(
1043        mut self,
1044        bin_path: &str,
1045        quality: &CellOnFileQuality,
1046        verbose: bool,
1047    ) -> Result<MtxFinalData, ScDataErrors> {
1048        let kept = &quality.cells_to_keep;
1049        if kept.windows(2).any(|w| w[0] >= w[1]) {
1050            return Err(ScDataErrors::InvalidArgument(
1051                "cells_to_keep must be strictly ascending".into(),
1052            ));
1053        }
1054        let n_kept_cells = kept.len();
1055        let n_kept_genes = quality.genes_to_keep.len();
1056        let target_size = self.qc_params.target_size;
1057
1058        if n_kept_cells == 0 {
1059            let writer = CellGeneSparseWriter::new(bin_path, true, 0, n_kept_genes, target_size)?;
1060            writer.finalise()?;
1061            return Ok(MtxFinalData {
1062                cell_qc: CellQuality {
1063                    cell_indices: vec![],
1064                    gene_indices: quality.genes_to_keep.to_vec(),
1065                    lib_size: vec![],
1066                    nnz: vec![],
1067                },
1068                no_genes: n_kept_genes,
1069                no_cells: 0,
1070            });
1071        }
1072
1073        let spill = match self.spill.take() {
1074            Some(s) => s,
1075            None => self.text_pass(verbose)?,
1076        };
1077
1078        let start = Instant::now();
1079        if verbose {
1080            println!(
1081                "Writing pass: {} buckets{}",
1082                spill.files.0.len(),
1083                if spill.cell_major { ", no sort" } else { "" }
1084            );
1085        }
1086
1087        let cell_map = dense_index_map(kept);
1088        let gene_map = dense_index_map(&quality.genes_to_keep);
1089        let lookup = |map: &[u32], i: u32| -> Option<u32> {
1090            map.get(i as usize).copied().filter(|&m| m != INDEX_DROPPED)
1091        };
1092
1093        let mut writer =
1094            CellGeneSparseWriter::new(bin_path, true, n_kept_cells, n_kept_genes, target_size)?;
1095        let mut lib_size = Vec::with_capacity(n_kept_cells);
1096        let mut nnz = Vec::with_capacity(n_kept_cells);
1097        let n_buckets = spill.files.0.len();
1098
1099        for (bucket_idx, temp_path) in spill.files.0.iter().enumerate() {
1100            let raw = std::fs::read(temp_path)?;
1101            let bytes = if spill.cell_major {
1102                // segments in block order are in cell order
1103                let mut segs = spill.segments[bucket_idx].clone();
1104                segs.sort_unstable_by_key(|&(seq, off, _)| (seq, off));
1105                let mut ordered = Vec::with_capacity(raw.len());
1106                for (_, off, len) in segs {
1107                    ordered.extend_from_slice(&raw[off as usize..(off + len) as usize]);
1108                }
1109                ordered
1110            } else {
1111                raw
1112            };
1113
1114            let mut entries: Vec<(u32, u32, u32)> = bytes
1115                .par_chunks_exact(BUCKET_RECORD_LEN)
1116                .filter_map(|r| {
1117                    let word =
1118                        |k: usize| u32::from_le_bytes(r[k..k + 4].try_into().expect("4 bytes"));
1119                    Some((
1120                        lookup(&cell_map, word(0))?,
1121                        lookup(&gene_map, word(4))?,
1122                        word(8),
1123                    ))
1124                })
1125                .collect();
1126            drop(bytes);
1127            if !spill.cell_major {
1128                entries.par_sort_unstable();
1129            }
1130
1131            let first_cell = bucket_idx * spill.cells_per_bucket;
1132            let end_cell = first_cell + spill.cells_per_bucket;
1133            let k0 = kept.partition_point(|&c| c < first_cell);
1134            let k1 = kept.partition_point(|&c| c < end_cell);
1135            let run_starts: Vec<usize> = (k0..=k1)
1136                .map(|k| entries.partition_point(|e| (e.0 as usize) < k))
1137                .collect();
1138
1139            let built = (k0..k1)
1140                .into_par_iter()
1141                .map(|k| {
1142                    let j = k - k0;
1143                    let mut row: Vec<(u32, u32)> = entries[run_starts[j]..run_starts[j + 1]]
1144                        .iter()
1145                        .map(|&(_, g, v)| (g, v))
1146                        .collect();
1147                    compress_cell_row(&mut row, k, target_size, writer.format())
1148                })
1149                .collect::<Result<Vec<_>, ScDataErrors>>()?;
1150
1151            let mut payloads = Vec::with_capacity(built.len());
1152            for (nnz_i, payload) in built {
1153                nnz.push(nnz_i);
1154                lib_size.push(payload.library_size);
1155                payloads.push(payload);
1156            }
1157            writer.write_compressed_cell_chunks(&payloads)?;
1158
1159            let _ = std::fs::remove_file(temp_path);
1160
1161            if verbose
1162                && ((bucket_idx + 1) % (n_buckets / 10).max(1) == 0 || bucket_idx + 1 == n_buckets)
1163            {
1164                println!(
1165                    "  Wrote bucket {}/{} ({}%)",
1166                    bucket_idx + 1,
1167                    n_buckets,
1168                    (bucket_idx + 1) * 100 / n_buckets
1169                );
1170            }
1171        }
1172
1173        writer.finalise()?;
1174
1175        if verbose {
1176            println!("Writing pass done: {:.2?}", start.elapsed());
1177        }
1178
1179        Ok(MtxFinalData {
1180            cell_qc: CellQuality {
1181                cell_indices: kept.to_vec(),
1182                gene_indices: quality.genes_to_keep.to_vec(),
1183                lib_size,
1184                nnz,
1185            },
1186            no_genes: n_kept_genes,
1187            no_cells: n_kept_cells,
1188        })
1189    }
1190}
1191
1192///////////
1193// Tests //
1194///////////
1195
1196#[cfg(test)]
1197mod tests {
1198    use super::*;
1199    use crate::data_io::write_cell_rows;
1200
1201    ///////////////
1202    // Fixtures //
1203    ///////////////
1204
1205    /// Three cells x four genes, cells as rows, five entries. All indices are
1206    /// base-1 as the Matrix Market spec demands.
1207    ///
1208    /// ```text
1209    ///        g0 g1 g2 g3
1210    ///   c0 [  5  .  7  . ]
1211    ///   c1 [  .  3  .  . ]
1212    ///   c2 [  2  .  .  9 ]
1213    /// ```
1214    const CELLS_AS_ROWS_MTX: &str = "%%MatrixMarket matrix coordinate integer general\n\
1215%\n\
12163 4 5\n\
12171 1 5\n\
12181 3 7\n\
12192 2 3\n\
12203 1 2\n\
12213 4 9\n";
1222
1223    /// The transpose of [`CELLS_AS_ROWS_MTX`], i.e. genes as rows.
1224    const GENES_AS_ROWS_MTX: &str = "%%MatrixMarket matrix coordinate integer general\n\
1225%\n\
12264 3 5\n\
12271 1 5\n\
12283 1 7\n\
12292 2 3\n\
12301 3 2\n\
12314 3 9\n";
1232
1233    /// The `(gene indices, raw counts)` the two fixtures both encode.
1234    fn expected_cells() -> Vec<(Vec<u32>, Vec<u32>)> {
1235        vec![
1236            (vec![0, 2], vec![5, 7]),
1237            (vec![1], vec![3]),
1238            (vec![0, 3], vec![2, 9]),
1239        ]
1240    }
1241
1242    /// RAII guard that removes a test's scratch file even if an assert fails.
1243    struct TempPath(PathBuf);
1244
1245    /// Drop implementation for [`TempPath`]. Errors are ignored: the file may
1246    /// already be gone, and this runs during unwind.
1247    impl Drop for TempPath {
1248        fn drop(&mut self) {
1249            let _ = std::fs::remove_file(&self.0);
1250        }
1251    }
1252
1253    impl TempPath {
1254        /// Reserve a scratch file named after the calling test. The test name
1255        /// is the uniqueness guarantee: `cargo test` runs the whole module in
1256        /// one process, so a PID alone would collide across threads.
1257        fn new(name: &str, ext: &str) -> Self {
1258            Self(std::env::temp_dir().join(format!("bixverse_mtx_io_{name}.{ext}")))
1259        }
1260
1261        /// Path of the guarded file as a `&str`.
1262        fn path(&self) -> &str {
1263            self.0.to_str().expect("temp path is valid UTF-8")
1264        }
1265    }
1266
1267    /// Write `body` to a scratch `.mtx` file and hand back its guard.
1268    fn write_mtx(name: &str, body: &str) -> TempPath {
1269        let temp = TempPath::new(name, "mtx");
1270        std::fs::write(&temp.0, body).expect("mtx file written");
1271        temp
1272    }
1273
1274    /// QC thresholds that keep every cell and gene of the fixtures.
1275    fn keep_all_qc() -> MinCellQuality {
1276        MinCellQuality {
1277            min_unique_genes: 1,
1278            min_lib_size: 1,
1279            min_cells: 1,
1280            target_size: 1e4,
1281        }
1282    }
1283
1284    /// Read the first `n_cells` cells of a written store back as
1285    /// `(gene indices, raw counts)`.
1286    fn read_back(bin_path: &str, n_cells: usize) -> Vec<(Vec<u32>, Vec<u32>)> {
1287        let reader = ParallelSparseReader::new(bin_path).expect("reader opens");
1288        let indices: Vec<usize> = (0..n_cells).collect();
1289        reader
1290            .read_cells_parallel(&indices)
1291            .expect("cells read back")
1292            .into_iter()
1293            .map(|c| (c.indices.clone(), c.data_raw.iter().collect()))
1294            .collect()
1295    }
1296
1297    /////////////////
1298    // Line parser //
1299    /////////////////
1300
1301    /// Parse an integer-field line that must not raise a value error.
1302    fn int(line: &[u8]) -> Option<(u32, u32, u32)> {
1303        parse_entry(line, MtxField::Integer).expect("no value error")
1304    }
1305
1306    /// Regression: the parser used to saturate counts at `u16::MAX`.
1307    #[test]
1308    fn test_parse_mtx_line_keeps_full_u32_counts() {
1309        assert_eq!(int(b"1 2 3"), Some((1, 2, 3)));
1310        assert_eq!(int(b"7\t9\t65535"), Some((7, 9, 65_535)));
1311        assert_eq!(int(b"4 5 70000"), Some((4, 5, 70_000)));
1312        assert_eq!(int(b"1 2 4294967295"), Some((1, 2, u32::MAX)));
1313    }
1314
1315    /// Empty, non-numeric and short lines give `None`, not a partial triplet.
1316    #[test]
1317    fn test_parse_mtx_line_rejects_malformed_input() {
1318        assert_eq!(int(b""), None);
1319        assert_eq!(int(b"abc"), None);
1320        assert_eq!(int(b"1"), None);
1321        assert_eq!(int(b"1 2"), None);
1322    }
1323
1324    ////////////
1325    // Header //
1326    ////////////
1327
1328    /// The shape line is split by orientation, so both readings of the same
1329    /// three numbers have to be pinned.
1330    #[test]
1331    fn mtx_header_shape_line_is_read_per_orientation() {
1332        let mtx = write_mtx("header_shape", CELLS_AS_ROWS_MTX);
1333
1334        let cells_as_rows = MtxReader::new(mtx.path(), keep_all_qc(), true).expect("reader opens");
1335        assert_eq!(cells_as_rows.header.total_cells, 3);
1336        assert_eq!(cells_as_rows.header.total_genes, 4);
1337        assert_eq!(cells_as_rows.header.total_entries, 5);
1338
1339        // Same file read the other way round: the first two fields swap roles.
1340        let genes_as_rows = MtxReader::new(mtx.path(), keep_all_qc(), false).expect("reader opens");
1341        assert_eq!(genes_as_rows.header.total_cells, 4);
1342        assert_eq!(genes_as_rows.header.total_genes, 3);
1343        assert_eq!(genes_as_rows.header.total_entries, 5);
1344    }
1345
1346    /// A shape line that is not three fields must raise `MtxHeaderInvalid`
1347    /// rather than index past the end of the split.
1348    #[test]
1349    fn mtx_header_with_wrong_field_count_is_rejected() {
1350        let mtx = write_mtx(
1351            "header_two_fields",
1352            "%%MatrixMarket matrix coordinate integer general\n3 4\n1 1 5\n",
1353        );
1354
1355        assert!(matches!(
1356            MtxReader::new(mtx.path(), keep_all_qc(), true),
1357            Err(ScDataErrors::MtxHeaderInvalid(_))
1358        ));
1359    }
1360
1361    /// `MtxParseError` has to name the field that failed, and the name depends
1362    /// on the orientation because the first two fields swap.
1363    #[test]
1364    fn mtx_header_parse_error_names_the_failing_field() {
1365        let bad_second = write_mtx(
1366            "header_bad_second",
1367            "%%MatrixMarket matrix coordinate integer general\n3 x 5\n",
1368        );
1369        assert!(matches!(
1370            MtxReader::new(bad_second.path(), keep_all_qc(), true),
1371            Err(ScDataErrors::MtxParseError {
1372                field: "gene count"
1373            })
1374        ));
1375        // Cells are the columns now, so the same broken field is the cell count.
1376        assert!(matches!(
1377            MtxReader::new(bad_second.path(), keep_all_qc(), false),
1378            Err(ScDataErrors::MtxParseError {
1379                field: "cell count"
1380            })
1381        ));
1382
1383        let bad_third = write_mtx(
1384            "header_bad_third",
1385            "%%MatrixMarket matrix coordinate integer general\n3 4 x\n",
1386        );
1387        assert!(matches!(
1388            MtxReader::new(bad_third.path(), keep_all_qc(), true),
1389            Err(ScDataErrors::MtxParseError {
1390                field: "entry count"
1391            })
1392        ));
1393    }
1394
1395    /// A missing file must surface as an I/O error, not a panic on unwrap.
1396    #[test]
1397    fn mtx_reader_reports_a_missing_file() {
1398        let missing = std::env::temp_dir().join("bixverse_mtx_io_does_not_exist.mtx");
1399        assert!(matches!(
1400            MtxReader::new(&missing, keep_all_qc(), true),
1401            Err(ScDataErrors::BinaryIo(_))
1402        ));
1403    }
1404
1405    /////////////////
1406    // Quality pass //
1407    /////////////////
1408
1409    /// `parse_mtx_quality` drives every downstream index remap, so pin the
1410    /// gene-then-cell filter order and the old-to-new maps it builds.
1411    #[test]
1412    fn parse_mtx_quality_filters_genes_then_cells() {
1413        let mtx = write_mtx("quality_filters", CELLS_AS_ROWS_MTX);
1414
1415        // Everything permissive: nothing is dropped and the maps are identities.
1416        let mut reader = MtxReader::new(mtx.path(), keep_all_qc(), true).expect("reader opens");
1417        let quality = reader.parse_mtx_quality(false).expect("quality pass");
1418        assert_eq!(quality.cells_to_keep, vec![0, 1, 2]);
1419        assert_eq!(quality.genes_to_keep, vec![0, 1, 2, 3]);
1420
1421        // min_cells = 2 keeps only gene 0, which is seen in cells 0 and 2.
1422        // Cell 1 then has zero kept entries and fails min_unique_genes = 1.
1423        let qc = MinCellQuality {
1424            min_unique_genes: 1,
1425            min_lib_size: 1,
1426            min_cells: 2,
1427            target_size: 1e4,
1428        };
1429        let mut reader = MtxReader::new(mtx.path(), qc, true).expect("reader opens");
1430        let quality = reader.parse_mtx_quality(false).expect("quality pass");
1431        assert_eq!(quality.genes_to_keep, vec![0]);
1432        assert_eq!(quality.cells_to_keep, vec![0, 2]);
1433        assert_eq!(quality.cell_old_to_new[&0], 0);
1434        assert_eq!(quality.cell_old_to_new[&2], 1);
1435        assert_eq!(quality.gene_old_to_new[&0], 0);
1436    }
1437
1438    /////////////////
1439    // Round trips //
1440    /////////////////
1441
1442    /// The only mtx -> bin -> reader path in the crate. Counts and gene indices
1443    /// have to survive the conversion untouched.
1444    #[test]
1445    fn mtx_round_trip_preserves_counts_and_indices() {
1446        let mtx = write_mtx("round_trip", CELLS_AS_ROWS_MTX);
1447        let bin = TempPath::new("round_trip", "bin");
1448
1449        let mut reader = MtxReader::new(mtx.path(), keep_all_qc(), true).expect("reader opens");
1450        let quality = reader.parse_mtx_quality(false).expect("quality pass");
1451        let final_data = reader
1452            .process_mtx_and_write_bin(bin.path(), &quality, false)
1453            .expect("conversion");
1454
1455        assert_eq!(final_data.no_cells, 3);
1456        assert_eq!(final_data.no_genes, 4);
1457        // Library sizes: 5 + 7 = 12, 3, 2 + 9 = 11.
1458        assert_eq!(final_data.cell_qc.lib_size, vec![12, 3, 11]);
1459        assert_eq!(final_data.cell_qc.nnz, vec![2, 1, 2]);
1460
1461        assert_eq!(read_back(bin.path(), 3), expected_cells());
1462    }
1463
1464    /// Matrix Market is base-1. The lowest legal index has to land on 0 and the
1465    /// highest on `n - 1`, with nothing shifted in between.
1466    #[test]
1467    fn mtx_indices_shift_from_base_one_to_base_zero() {
1468        let mtx = write_mtx(
1469            "base_one",
1470            "%%MatrixMarket matrix coordinate integer general\n2 3 2\n1 1 42\n2 3 7\n",
1471        );
1472        let bin = TempPath::new("base_one", "bin");
1473
1474        let mut reader = MtxReader::new(mtx.path(), keep_all_qc(), true).expect("reader opens");
1475        let quality = reader.parse_mtx_quality(false).expect("quality pass");
1476        reader
1477            .process_mtx_and_write_bin(bin.path(), &quality, false)
1478            .expect("conversion");
1479
1480        // Only genes 1 and 3 (base-1) carry counts, so the kept genes are the
1481        // old 0 and 2, renumbered to 0 and 1.
1482        assert_eq!(quality.genes_to_keep, vec![0, 2]);
1483        assert_eq!(
1484            read_back(bin.path(), 2),
1485            vec![(vec![0], vec![42]), (vec![1], vec![7])]
1486        );
1487    }
1488
1489    /// The two orientations parse different files but must produce the same
1490    /// store, which is the only cross-check on the row/column swap.
1491    #[test]
1492    fn mtx_gene_as_rows_matches_cell_as_rows() {
1493        let mtx = write_mtx("orientation", GENES_AS_ROWS_MTX);
1494        let bin = TempPath::new("orientation", "bin");
1495
1496        let mut reader = MtxReader::new(mtx.path(), keep_all_qc(), false).expect("reader opens");
1497        let quality = reader.parse_mtx_quality(false).expect("quality pass");
1498        let final_data = reader
1499            .process_mtx_and_write_bin(bin.path(), &quality, false)
1500            .expect("conversion");
1501
1502        assert_eq!(final_data.no_cells, 3);
1503        assert_eq!(final_data.no_genes, 4);
1504        assert_eq!(final_data.cell_qc.lib_size, vec![12, 3, 11]);
1505        assert_eq!(read_back(bin.path(), 3), expected_cells());
1506    }
1507
1508    /// The streaming writer buckets to temp files and re-sorts; it must land on
1509    /// exactly the same store as the in-memory writer.
1510    #[test]
1511    fn mtx_streaming_matches_non_streaming() {
1512        let mtx = write_mtx("streaming", CELLS_AS_ROWS_MTX);
1513        let bin_plain = TempPath::new("streaming_plain", "bin");
1514        let bin_stream = TempPath::new("streaming_stream", "bin");
1515
1516        let mut reader = MtxReader::new(mtx.path(), keep_all_qc(), true).expect("reader opens");
1517        let quality = reader.parse_mtx_quality(false).expect("quality pass");
1518        let plain = reader
1519            .process_mtx_and_write_bin(bin_plain.path(), &quality, false)
1520            .expect("conversion");
1521
1522        let reader = MtxReader::new(mtx.path(), keep_all_qc(), true).expect("reader opens");
1523        let streamed = reader
1524            .process_mtx_and_write_bin_streaming(bin_stream.path(), &quality, false)
1525            .expect("streaming conversion");
1526
1527        assert_eq!(streamed.no_cells, plain.no_cells);
1528        assert_eq!(streamed.no_genes, plain.no_genes);
1529        assert_eq!(streamed.cell_qc.lib_size, plain.cell_qc.lib_size);
1530        assert_eq!(streamed.cell_qc.nnz, plain.cell_qc.nnz);
1531        assert_eq!(
1532            read_back(bin_stream.path(), 3),
1533            read_back(bin_plain.path(), 3)
1534        );
1535    }
1536
1537    /////////////////////
1538    // Reference store //
1539    /////////////////////
1540
1541    /// Entry order of a generated mtx file.
1542    #[derive(Clone, Copy, Debug)]
1543    enum EntryOrder {
1544        /// All entries of a cell together, cells ascending
1545        CellMajor,
1546        /// All entries of a gene together, genes ascending
1547        GeneMajor,
1548        /// Seeded shuffle
1549        Shuffled,
1550    }
1551
1552    /// Random sparse counts with rare genes and weak cells, so QC drops both.
1553    ///
1554    /// ### Params
1555    ///
1556    /// * `n_cells` - Number of cells
1557    /// * `n_genes` - Number of genes
1558    /// * `seed` - Seed
1559    ///
1560    /// ### Returns
1561    ///
1562    /// Unique `(cell, gene, count)` triplets, 0-based, cell-major.
1563    fn random_counts(n_cells: usize, n_genes: usize, seed: u64) -> Vec<(u32, u32, u32)> {
1564        use rand::rngs::StdRng;
1565        use rand::{Rng, SeedableRng};
1566        let mut rng = StdRng::seed_from_u64(seed);
1567        let mut out = Vec::new();
1568        for c in 0..n_cells {
1569            let k = rng.random_range(0..40usize);
1570            let mut genes: Vec<u32> = (0..k)
1571                .map(|_| {
1572                    // squared uniform skews towards low indices, leaving the
1573                    // top of the range rare
1574                    let u: f64 = rng.random();
1575                    ((u * u * u) * n_genes as f64) as u32
1576                })
1577                .collect();
1578            genes.sort_unstable();
1579            genes.dedup();
1580            for g in genes {
1581                let v = if rng.random_range(0..200) == 0 {
1582                    70_000
1583                } else {
1584                    rng.random_range(1..30)
1585                };
1586                out.push((c as u32, g, v));
1587            }
1588        }
1589        out
1590    }
1591
1592    /// Render triplets as an mtx file body.
1593    ///
1594    /// ### Params
1595    ///
1596    /// * `entries` - `(cell, gene, count)`, 0-based
1597    /// * `n_cells` - Number of cells
1598    /// * `n_genes` - Number of genes
1599    /// * `cells_as_rows` - Orientation
1600    /// * `order` - Entry order
1601    ///
1602    /// ### Returns
1603    ///
1604    /// The file text.
1605    fn render_mtx(
1606        entries: &[(u32, u32, u32)],
1607        n_cells: usize,
1608        n_genes: usize,
1609        cells_as_rows: bool,
1610        order: EntryOrder,
1611    ) -> String {
1612        use rand::SeedableRng;
1613        use rand::rngs::StdRng;
1614        use rand::seq::SliceRandom;
1615        let mut e = entries.to_vec();
1616        match order {
1617            EntryOrder::CellMajor => {}
1618            EntryOrder::GeneMajor => e.sort_by_key(|&(c, g, _)| (g, c)),
1619            EntryOrder::Shuffled => e.shuffle(&mut StdRng::seed_from_u64(7)),
1620        }
1621        let (rows, cols) = if cells_as_rows {
1622            (n_cells, n_genes)
1623        } else {
1624            (n_genes, n_cells)
1625        };
1626        let mut s = format!(
1627            "%%MatrixMarket matrix coordinate integer general\n%\n{rows} {cols} {}\n",
1628            e.len()
1629        );
1630        for (c, g, v) in e {
1631            let (r, k) = if cells_as_rows { (c, g) } else { (g, c) };
1632            s.push_str(&format!("{} {} {v}\n", r + 1, k + 1));
1633        }
1634        s
1635    }
1636
1637    /// Independent reference loader: QC and store written straight from the
1638    /// triplets through [`write_cell_rows`].
1639    ///
1640    /// ### Params
1641    ///
1642    /// * `entries` - `(cell, gene, count)`, 0-based
1643    /// * `n_cells` - Number of cells
1644    /// * `n_genes` - Number of genes
1645    /// * `qc` - Thresholds
1646    /// * `bin_path` - Store to write
1647    ///
1648    /// ### Returns
1649    ///
1650    /// Kept cells and kept genes.
1651    fn reference_store(
1652        entries: &[(u32, u32, u32)],
1653        n_cells: usize,
1654        n_genes: usize,
1655        qc: &MinCellQuality,
1656        bin_path: &str,
1657    ) -> (Vec<usize>, Vec<usize>) {
1658        let mut gene_nnz = vec![0usize; n_genes];
1659        for &(_, g, _) in entries {
1660            gene_nnz[g as usize] += 1;
1661        }
1662        let genes: Vec<usize> = (0..n_genes)
1663            .filter(|&g| gene_nnz[g] >= qc.min_cells)
1664            .collect();
1665        let gene_map = dense_index_map(&genes);
1666
1667        let mut stats = vec![(0usize, 0u64); n_cells];
1668        for &(c, g, v) in entries {
1669            if gene_map
1670                .get(g as usize)
1671                .is_some_and(|&m| m != INDEX_DROPPED)
1672            {
1673                stats[c as usize].0 += 1;
1674                stats[c as usize].1 += v as u64;
1675            }
1676        }
1677        let cells: Vec<usize> = (0..n_cells)
1678            .filter(|&c| {
1679                stats[c].0 >= qc.min_unique_genes && stats[c].1 as f32 >= qc.min_lib_size as f32
1680            })
1681            .collect();
1682        let cell_map = dense_index_map(&cells);
1683
1684        let mut rows: Vec<Vec<(u32, u32)>> = vec![Vec::new(); cells.len()];
1685        for &(c, g, v) in entries {
1686            let (Some(&nc), Some(&ng)) = (cell_map.get(c as usize), gene_map.get(g as usize))
1687            else {
1688                continue;
1689            };
1690            if nc != INDEX_DROPPED && ng != INDEX_DROPPED {
1691                rows[nc as usize].push((ng, v));
1692            }
1693        }
1694        let mut writer =
1695            CellGeneSparseWriter::new(bin_path, true, cells.len(), genes.len(), qc.target_size)
1696                .expect("writer");
1697        write_cell_rows(&mut rows, 0, qc.target_size, &mut writer).expect("rows written");
1698        writer.finalise().expect("finalised");
1699        (cells, genes)
1700    }
1701
1702    /// QC that drops some genes and some cells of [`random_counts`].
1703    fn dropping_qc() -> MinCellQuality {
1704        MinCellQuality {
1705            min_unique_genes: 8,
1706            min_lib_size: 60,
1707            min_cells: 3,
1708            target_size: 1e4,
1709        }
1710    }
1711
1712    /// The loader's store must be byte identical to the reference for both
1713    /// orientations and every entry order, with QC dropping cells and genes.
1714    #[test]
1715    fn mtx_store_matches_reference_for_every_layout() {
1716        let (n_cells, n_genes) = (400, 1_500);
1717        let entries = random_counts(n_cells, n_genes, 42);
1718        let qc = dropping_qc();
1719        let reference = TempPath::new("reference_store", "bin");
1720        let (cells, genes) = reference_store(&entries, n_cells, n_genes, &qc, reference.path());
1721        assert!(
1722            cells.len() < n_cells && genes.len() < n_genes,
1723            "QC drops both"
1724        );
1725        let expected = std::fs::read(&reference.0).expect("reference store");
1726
1727        for cells_as_rows in [true, false] {
1728            for order in [
1729                EntryOrder::CellMajor,
1730                EntryOrder::GeneMajor,
1731                EntryOrder::Shuffled,
1732            ] {
1733                let body = render_mtx(&entries, n_cells, n_genes, cells_as_rows, order);
1734                // (gzip, block size): one block, and blocks small enough that
1735                // cells straddle them and the pipeline sees many
1736                for (gz, block_bytes) in [(false, MTX_BLOCK_BYTES), (false, 101), (true, 101)] {
1737                    let name = format!("layout_{cells_as_rows}_{order:?}_{gz}_{block_bytes}");
1738                    let mtx = if gz {
1739                        write_gz(&name, &body, 2)
1740                    } else {
1741                        write_mtx(&name, &body)
1742                    };
1743                    let bin = TempPath::new(&name, "bin");
1744
1745                    let mut reader = MtxReader::new(mtx.path(), qc.clone(), cells_as_rows)
1746                        .expect("reader opens");
1747                    reader.source.block_bytes = block_bytes;
1748                    let quality = reader.parse_mtx_quality(false).expect("quality pass");
1749                    assert_eq!(quality.cells_to_keep, cells, "{name}: kept cells");
1750                    assert_eq!(quality.genes_to_keep, genes, "{name}: kept genes");
1751                    let spill = reader.spill.as_ref().expect("spilled");
1752                    assert_eq!(
1753                        spill.cell_major,
1754                        matches!(order, EntryOrder::CellMajor),
1755                        "{name}: order detection"
1756                    );
1757                    reader
1758                        .process_mtx_and_write_bin_streaming(bin.path(), &quality, false)
1759                        .expect("conversion");
1760                    let got = std::fs::read(&bin.0).expect("store");
1761                    assert!(got == expected, "{name}: store differs from the reference");
1762                }
1763            }
1764        }
1765    }
1766
1767    ////////////////
1768    // Field type //
1769    ////////////////
1770
1771    /// Write `body` as a gzip of `members` concatenated members, as bgzip or
1772    /// `cat a.gz b.gz` produce.
1773    fn write_gz(name: &str, body: &str, members: usize) -> TempPath {
1774        use flate2::Compression;
1775        use flate2::write::GzEncoder;
1776        let temp = TempPath::new(name, "mtx.gz");
1777        let bytes = body.as_bytes();
1778        let step = bytes.len().div_ceil(members.max(1)).max(1);
1779        let mut out = Vec::new();
1780        for part in bytes.chunks(step) {
1781            let mut enc = GzEncoder::new(Vec::new(), Compression::fast());
1782            enc.write_all(part).expect("gzip member");
1783            out.extend(enc.finish().expect("gzip member"));
1784        }
1785        std::fs::write(&temp.0, out).expect("gz file written");
1786        temp
1787    }
1788
1789    /// Integral reals, scientific notation included, parse as counts; a
1790    /// fraction, a negative or garbage is an error, never a truncation.
1791    #[test]
1792    fn real_values_must_be_integral_counts() {
1793        let real = |l: &[u8]| parse_entry(l, MtxField::Real);
1794        assert_eq!(real(b"1 2 3").unwrap(), Some((1, 2, 3)));
1795        assert_eq!(real(b"1 2 3.0").unwrap(), Some((1, 2, 3)));
1796        assert_eq!(real(b"1 2 1.5e+01").unwrap(), Some((1, 2, 15)));
1797        assert_eq!(real(b"1 2 2.000000e+00").unwrap(), Some((1, 2, 2)));
1798        assert_eq!(real(b"1 2 0").unwrap(), Some((1, 2, 0)));
1799        for bad in [
1800            &b"1 2 1.5"[..],
1801            b"1 2 -1",
1802            b"1 2 abc",
1803            b"1 2 1e300",
1804            b"1 2 nan",
1805        ] {
1806            assert!(
1807                matches!(real(bad), Err(ScDataErrors::MtxNotACount { .. })),
1808                "{}",
1809                String::from_utf8_lossy(bad)
1810            );
1811        }
1812        // an integer field with a fractional value is just as wrong
1813        assert!(matches!(
1814            parse_entry(b"1 2 1.5", MtxField::Integer),
1815            Err(ScDataErrors::MtxNotACount { .. })
1816        ));
1817    }
1818
1819    /// A pattern entry has no value column and counts 1.
1820    #[test]
1821    fn pattern_entries_count_one() {
1822        assert_eq!(
1823            parse_entry(b"3 4", MtxField::Pattern).unwrap(),
1824            Some((3, 4, 1))
1825        );
1826        assert_eq!(
1827            parse_entry(b"3 4 9", MtxField::Pattern).unwrap(),
1828            Some((3, 4, 1))
1829        );
1830        assert_eq!(parse_entry(b"3", MtxField::Pattern).unwrap(), None);
1831    }
1832
1833    /// The banner decides the field; complex, dense and symmetric files are
1834    /// rejected up front.
1835    #[test]
1836    fn mtx_banner_field_and_symmetry() {
1837        let shape = "3 4 5\n1 1 5\n";
1838        let open = |name: &str, banner: &str| {
1839            let mtx = write_mtx(name, &format!("{banner}\n{shape}"));
1840            MtxReader::new(mtx.path(), keep_all_qc(), true).map(|r| r.header.field)
1841        };
1842        assert_eq!(
1843            open(
1844                "banner_int",
1845                "%%MatrixMarket matrix coordinate integer general"
1846            )
1847            .unwrap(),
1848            MtxField::Integer
1849        );
1850        assert_eq!(
1851            open(
1852                "banner_real",
1853                "%%MatrixMarket Matrix Coordinate Real General"
1854            )
1855            .unwrap(),
1856            MtxField::Real
1857        );
1858        assert_eq!(
1859            open(
1860                "banner_pattern",
1861                "%%MatrixMarket matrix coordinate pattern general"
1862            )
1863            .unwrap(),
1864            MtxField::Pattern
1865        );
1866        assert_eq!(
1867            open("banner_none", "% no banner").unwrap(),
1868            MtxField::Integer
1869        );
1870        for (name, banner) in [
1871            (
1872                "banner_complex",
1873                "%%MatrixMarket matrix coordinate complex general",
1874            ),
1875            (
1876                "banner_sym",
1877                "%%MatrixMarket matrix coordinate integer symmetric",
1878            ),
1879            (
1880                "banner_array",
1881                "%%MatrixMarket matrix array integer general",
1882            ),
1883        ] {
1884            assert!(
1885                matches!(open(name, banner), Err(ScDataErrors::MtxUnsupported(_))),
1886                "{name}"
1887            );
1888        }
1889    }
1890
1891    /// A real file of integral values and a pattern file load end to end, and
1892    /// a real file with a fraction fails the quality pass.
1893    #[test]
1894    fn mtx_real_and_pattern_files_round_trip() {
1895        let real = write_mtx(
1896            "real_file",
1897            "%%MatrixMarket matrix coordinate real general\n2 3 3\n1 1 5.0\n1 3 1.2e+01\n2 2 3\n",
1898        );
1899        let bin = TempPath::new("real_file", "bin");
1900        let mut reader = MtxReader::new(real.path(), keep_all_qc(), true).expect("reader opens");
1901        let quality = reader.parse_mtx_quality(false).expect("quality pass");
1902        let data = reader
1903            .process_mtx_and_write_bin_streaming(bin.path(), &quality, false)
1904            .expect("conversion");
1905        assert_eq!(data.cell_qc.lib_size, vec![17, 3]);
1906        assert_eq!(
1907            read_back(bin.path(), 2),
1908            vec![(vec![0, 2], vec![5, 12]), (vec![1], vec![3])]
1909        );
1910
1911        let pattern = write_mtx(
1912            "pattern_file",
1913            "%%MatrixMarket matrix coordinate pattern general\n2 3 3\n1 1\n1 3\n2 2\n",
1914        );
1915        let bin = TempPath::new("pattern_file", "bin");
1916        let mut reader = MtxReader::new(pattern.path(), keep_all_qc(), true).expect("reader opens");
1917        let quality = reader.parse_mtx_quality(false).expect("quality pass");
1918        reader
1919            .process_mtx_and_write_bin_streaming(bin.path(), &quality, false)
1920            .expect("conversion");
1921        assert_eq!(
1922            read_back(bin.path(), 2),
1923            vec![(vec![0, 2], vec![1, 1]), (vec![1], vec![1])]
1924        );
1925
1926        let fraction = write_mtx(
1927            "real_fraction",
1928            "%%MatrixMarket matrix coordinate real general\n2 3 2\n1 1 5.0\n2 2 0.25\n",
1929        );
1930        let mut reader =
1931            MtxReader::new(fraction.path(), keep_all_qc(), true).expect("reader opens");
1932        assert!(matches!(
1933            reader.parse_mtx_quality(false),
1934            Err(ScDataErrors::MtxNotACount { .. })
1935        ));
1936    }
1937
1938    /// A gzipped file is recognised by its magic bytes whatever its name.
1939    #[test]
1940    fn mtx_gzip_is_detected_by_magic_bytes() {
1941        let gz = write_gz("gz_magic", CELLS_AS_ROWS_MTX, 1);
1942        let renamed = TempPath::new("gz_magic_renamed", "mtx");
1943        std::fs::copy(&gz.0, &renamed.0).expect("copy");
1944        let bin = TempPath::new("gz_magic", "bin");
1945        let mut reader = MtxReader::new(renamed.path(), keep_all_qc(), true).expect("reader opens");
1946        assert!(reader.source.gz);
1947        let quality = reader.parse_mtx_quality(false).expect("quality pass");
1948        reader
1949            .process_mtx_and_write_bin_streaming(bin.path(), &quality, false)
1950            .expect("conversion");
1951        assert_eq!(read_back(bin.path(), 3), expected_cells());
1952    }
1953
1954    /// An empty input has no cells to keep; the streaming path short-circuits
1955    /// there and still has to leave a readable, empty store behind.
1956    #[test]
1957    fn mtx_streaming_writes_a_readable_store_when_nothing_is_kept() {
1958        let bin = TempPath::new("streaming_empty", "bin");
1959        let mtx = write_mtx("streaming_empty", CELLS_AS_ROWS_MTX);
1960
1961        let reader = MtxReader::new(mtx.path(), keep_all_qc(), true).expect("reader opens");
1962        let mut quality = CellOnFileQuality::new(vec![], vec![0, 1, 2, 3]);
1963        quality.generate_maps_sets();
1964
1965        let final_data = reader
1966            .process_mtx_and_write_bin_streaming(bin.path(), &quality, false)
1967            .expect("streaming conversion");
1968
1969        assert_eq!(final_data.no_cells, 0);
1970        assert_eq!(final_data.no_genes, 4);
1971        assert!(final_data.cell_qc.lib_size.is_empty());
1972
1973        let reader = ParallelSparseReader::new(bin.path()).expect("reader opens");
1974        assert!(reader.is_cell_based());
1975        assert_eq!(reader.get_header().total_cells, 0);
1976    }
1977
1978    /// A kept cell with no kept entries is still written, as an empty chunk,
1979    /// so `lib_size` / `nnz` line up with `cell_indices` in both writers.
1980    #[test]
1981    fn mtx_kept_cell_without_entries_is_written_empty() {
1982        for streaming in [false, true] {
1983            let name = format!("empty_cell_{streaming}");
1984            let mtx = write_mtx(&name, CELLS_AS_ROWS_MTX);
1985            let bin = TempPath::new(&name, "bin");
1986
1987            // Keep every cell but only gene 0, which cell 1 does not express.
1988            let reader = MtxReader::new(mtx.path(), keep_all_qc(), true).expect("reader opens");
1989            let mut quality = CellOnFileQuality::new(vec![0, 1, 2], vec![0]);
1990            quality.generate_maps_sets();
1991
1992            let final_data = if streaming {
1993                reader.process_mtx_and_write_bin_streaming(bin.path(), &quality, false)
1994            } else {
1995                reader.process_mtx_and_write_bin(bin.path(), &quality, false)
1996            }
1997            .expect("conversion");
1998
1999            assert_eq!(final_data.cell_qc.cell_indices.len(), 3);
2000            assert_eq!(final_data.cell_qc.lib_size, vec![5, 0, 2]);
2001            assert_eq!(final_data.cell_qc.nnz, vec![1, 0, 1]);
2002
2003            let store = ParallelSparseReader::new(bin.path()).expect("reader opens");
2004            let cell = store
2005                .read_cells_parallel(&[1])
2006                .expect("empty cell is on disk");
2007            assert!(cell[0].indices.is_empty());
2008        }
2009    }
2010}