Skip to main content

bixverse_sc_data/
v4_io.rs

1//! The v4 chunk encoding and the v3 -> v4 converters.
2//!
3//! v4 keeps the v3 container (per-chunk length prefix, offsets, index map,
4//! library-size index) and changes what a chunk holds. Per chunk:
5//!
6//! * gene (or cell) indices as delta bit-packed blocks of 128 instead of
7//!   absolute `u32`, which lz4 cannot compress;
8//! * raw counts bit-packed the same way instead of `u16` / `u32`;
9//! * the normalised layer dropped when it recomputes bit for bit from the raw
10//!   counts, library size and target size, stored verbatim otherwise;
11//! * an uncompressed fixed header, so nnz and library size are readable
12//!   without decompressing, and an optional byte codec over the body.
13//!
14//! Gene-based files recompute the norm of an entry from the library size of
15//! its cell, so they carry a per-cell library-size table in the tail, pointed
16//! at by the file header's `library_index_offset`.
17//!
18//! Chunk layout (all little endian):
19//!
20//! ```text
21//! [0..4)   nnz            u32
22//! [4..8)   body_len       u32   decoded body length
23//! [8..16)  aux            u64   cell: library size; gene: avg_exp f16 bits
24//! [16..24) original_index u64
25//! [24]     to_keep        u8
26//! [25]     flags          u8    bit 0: norm stored, bit 1: raw stored minus one
27//! [26]     codec          u8    0 none, 1 lz4, 2 zstd
28//! [27..32) reserved
29//! ```
30//!
31//! The body is the packed indices, the packed raw counts and, when flagged,
32//! the f16 norm layer, then compressed with `codec`.
33
34use lz4_flex::block::{compress as lz4_compress, decompress_into as lz4_decompress_into};
35use rayon::prelude::*;
36use std::time::Instant;
37use thousands::Separable;
38
39use crate::data_io::*;
40use crate::prelude::*;
41
42////////////
43// Consts //
44////////////
45
46/// Length of the fixed v4 chunk header, see the module docs.
47pub(crate) const V4_HEADER_LEN: usize = 32;
48
49/// Values per bit-packed block. Each block carries its own width, so one large
50/// delta only widens its own block, and decoding keeps a short fixed trip
51/// count.
52const PACK_BLOCK: usize = 128;
53
54/// Header flag: the norm layer is stored verbatim after the raw counts.
55const FLAG_NORM_STORED: u8 = 1;
56
57/// Header flag: raw counts are stored as `count - 1`. Set when every count is
58/// at least one, which holds for any non-zero entry and saves a bit per value.
59const FLAG_RAW_MINUS_ONE: u8 = 2;
60
61/// Largest count the per-cell norm lookup table covers. Counts above it fall
62/// back to one `ln_1p` each; they are rare in UMI data.
63const NORM_LUT_MAX: usize = 255;
64
65/// Counts covered by the per-cell norm table of a gene file, see
66/// [`gene_norm_table`]. Larger counts fall back to one `ln_1p`. Not tuned: it
67/// trades table size (two bytes per cell per count) against how often the
68/// fallback runs.
69pub(crate) const GENE_NORM_LUT_COUNTS: usize = 4;
70
71/// Zstd level for [`ChunkCodec::Zstd`]. Low on purpose: decode speed barely
72/// moves with level, so higher levels only cost encode time for little gain on
73/// payloads this small.
74const ZSTD_LEVEL: i32 = 1;
75
76/// Cell chunks converted per parallel batch. Bounds the decoded v3 chunks and
77/// the compressed v4 payloads held at once.
78const CONVERT_CELL_BATCH: usize = 4_096;
79
80/// Gene chunks converted per parallel batch. Genes are far larger than cells
81/// (one entry per expressing cell), so the batch is much smaller.
82const CONVERT_GENE_BATCH: usize = 64;
83
84///////////
85// Codec //
86///////////
87
88/// Byte codec applied to a v4 chunk body.
89///
90/// Recorded per chunk, so a chunk that does not shrink is stored as
91/// [`ChunkCodec::None`] whatever the file was asked for.
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub enum ChunkCodec {
94    /// Body stored as is.
95    None,
96    /// lz4 block. Fastest to decode.
97    Lz4,
98    /// zstd at a low level. Smaller than lz4, slower to decode.
99    Zstd,
100}
101
102impl ChunkCodec {
103    /// The byte stored in the chunk header.
104    ///
105    /// ### Returns
106    ///
107    /// 0 for none, 1 for lz4, 2 for zstd.
108    fn to_u8(self) -> u8 {
109        match self {
110            ChunkCodec::None => 0,
111            ChunkCodec::Lz4 => 1,
112            ChunkCodec::Zstd => 2,
113        }
114    }
115
116    /// Parse the byte stored in a chunk header.
117    ///
118    /// ### Params
119    ///
120    /// * `b` - The header byte
121    ///
122    /// ### Returns
123    ///
124    /// The codec, or [`ScDataErrors::V4ChunkCorrupt`] for an unknown byte.
125    fn from_u8(b: u8) -> Result<Self, ScDataErrors> {
126        match b {
127            0 => Ok(ChunkCodec::None),
128            1 => Ok(ChunkCodec::Lz4),
129            2 => Ok(ChunkCodec::Zstd),
130            other => Err(ScDataErrors::V4ChunkCorrupt(format!(
131                "unknown codec byte {other}"
132            ))),
133        }
134    }
135}
136
137/////////////////
138// Bit packing //
139/////////////////
140
141/// Bit-pack values in blocks of [`PACK_BLOCK`]: per block one width byte, then
142/// the values at that width, padded to a whole byte.
143///
144/// ### Params
145///
146/// * `vals` - The values
147/// * `out` - Destination, appended to
148fn pack(vals: &[u32], out: &mut Vec<u8>) {
149    for block in vals.chunks(PACK_BLOCK) {
150        let max = block.iter().copied().max().unwrap_or(0);
151        let width = (32 - max.leading_zeros()) as usize;
152        out.push(width as u8);
153        let (mut acc, mut nbits) = (0u64, 0usize);
154        for &v in block {
155            acc |= (v as u64) << nbits;
156            nbits += width;
157            while nbits >= 8 {
158                out.push(acc as u8);
159                acc >>= 8;
160                nbits -= 8;
161            }
162        }
163        if nbits > 0 {
164            out.push(acc as u8);
165        }
166    }
167}
168
169/// Read eight little-endian bytes at `at`, zero padded past the end.
170///
171/// ### Params
172///
173/// * `b` - The block bytes
174/// * `at` - Start offset, at most `b.len()`
175///
176/// ### Returns
177///
178/// The word.
179#[inline(always)]
180fn read_word(b: &[u8], at: usize) -> u64 {
181    match b.get(at..at + 8) {
182        Some(s) => u64::from_le_bytes(s.try_into().expect("8-byte slice")),
183        None => {
184            let mut tmp = [0u8; 8];
185            let rest = &b[at..];
186            tmp[..rest.len()].copy_from_slice(rest);
187            u64::from_le_bytes(tmp)
188        }
189    }
190}
191
192/// Unpack one full block of [`PACK_BLOCK`] values at the compile-time width
193/// `W`.
194///
195/// Eight values at width `W` occupy exactly `W` bytes, so group `g` starts at
196/// byte `g * W` and every shift and offset inside a group is a constant. That
197/// lets the compiler unroll and vectorise what the generic loop cannot.
198///
199/// ### Params
200///
201/// * `src` - At least `16 * W + 8` bytes starting at the block's packed data;
202///   the slack lets every value be read as one unaligned word
203/// * `out` - The 128 decoded values
204#[inline(always)]
205fn unpack_full<const W: usize>(src: &[u8], out: &mut [u32; PACK_BLOCK]) {
206    let mask = if W >= 32 { u32::MAX } else { (1u32 << W) - 1 };
207    for g in 0..PACK_BLOCK / 8 {
208        for j in 0..8 {
209            let bit = j * W;
210            let at = g * W + (bit >> 3);
211            let word = u64::from_le_bytes(src[at..at + 8].try_into().expect("8-byte slice"));
212            out[g * 8 + j] = ((word >> (bit & 7)) as u32) & mask;
213        }
214    }
215}
216
217/// Dispatch [`unpack_full`] on a runtime width in `1..=32`.
218///
219/// ### Params
220///
221/// * `width` - Bit width of the block
222/// * `src` - See [`unpack_full`]
223/// * `out` - The 128 decoded values
224#[inline(always)]
225fn unpack_full_dispatch(width: usize, src: &[u8], out: &mut [u32; PACK_BLOCK]) {
226    macro_rules! by_width {
227        ($($w:literal),*) => {
228            match width {
229                $($w => unpack_full::<$w>(src, out),)*
230                _ => unreachable!("width checked to be 1..=32"),
231            }
232        };
233    }
234    by_width!(
235        1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
236        26, 27, 28, 29, 30, 31, 32
237    );
238}
239
240/// Unpack `n` values written by [`pack`].
241///
242/// Full blocks with slack behind them go through the width-specialised
243/// [`unpack_full`]; the last partial block, and a full block that ends the
244/// buffer, take the generic path.
245///
246/// ### Params
247///
248/// * `buf` - Bytes starting at the first block
249/// * `n` - Number of values
250/// * `out` - Destination, appended to
251///
252/// ### Returns
253///
254/// The bytes consumed, or [`ScDataErrors::V4ChunkCorrupt`] for a bad width or
255/// a buffer that ends early.
256fn unpack(buf: &[u8], n: usize, out: &mut Vec<u32>) -> Result<usize, ScDataErrors> {
257    out.reserve(n);
258    let mut pos = 0usize;
259    let mut left = n;
260    while left > 0 {
261        let take = left.min(PACK_BLOCK);
262        let width = *buf.get(pos).ok_or_else(truncated)? as usize;
263        if width > 32 {
264            return Err(ScDataErrors::V4ChunkCorrupt(format!(
265                "bit width {width} above 32"
266            )));
267        }
268        pos += 1;
269        let need = (take * width).div_ceil(8);
270        let block = buf.get(pos..pos + need).ok_or_else(truncated)?;
271
272        if width == 0 {
273            out.resize(out.len() + take, 0);
274        } else if take == PACK_BLOCK && buf.len() >= pos + need + 8 {
275            let at = out.len();
276            out.resize(at + PACK_BLOCK, 0);
277            let dst: &mut [u32; PACK_BLOCK] = (&mut out[at..at + PACK_BLOCK])
278                .try_into()
279                .expect("128-value slice");
280            unpack_full_dispatch(width, &buf[pos..], dst);
281        } else {
282            let mask = if width == 32 {
283                u32::MAX
284            } else {
285                (1u32 << width) - 1
286            };
287            for k in 0..take {
288                let bit = k * width;
289                out.push(((read_word(block, bit >> 3) >> (bit & 7)) as u32) & mask);
290            }
291        }
292        pos += need;
293        left -= take;
294    }
295    Ok(pos)
296}
297
298/// Bytes [`pack`] used for `n` values, found by walking the block widths.
299///
300/// ### Params
301///
302/// * `buf` - Bytes starting at the first block
303/// * `n` - Number of values
304///
305/// ### Returns
306///
307/// The packed length, or [`ScDataErrors::V4ChunkCorrupt`] for a bad width or a
308/// buffer that ends early.
309fn packed_len(buf: &[u8], n: usize) -> Result<usize, ScDataErrors> {
310    let mut pos = 0usize;
311    let mut left = n;
312    while left > 0 {
313        let take = left.min(PACK_BLOCK);
314        let width = *buf.get(pos).ok_or_else(truncated)? as usize;
315        if width > 32 {
316            return Err(ScDataErrors::V4ChunkCorrupt(format!(
317                "bit width {width} above 32"
318            )));
319        }
320        pos += 1 + (take * width).div_ceil(8);
321        left -= take;
322    }
323    if pos > buf.len() {
324        return Err(truncated());
325    }
326    Ok(pos)
327}
328
329/// The error for a body that ends before its own header says it should.
330///
331/// ### Returns
332///
333/// [`ScDataErrors::V4ChunkCorrupt`].
334fn truncated() -> ScDataErrors {
335    ScDataErrors::V4ChunkCorrupt("body ends early".to_string())
336}
337
338////////////
339// Header //
340////////////
341
342/// Parsed fixed header of a v4 chunk.
343#[derive(Clone, Copy, Debug)]
344pub(crate) struct V4Header {
345    /// Entries in the chunk.
346    pub nnz: usize,
347    /// Decoded body length in bytes.
348    body_len: usize,
349    /// Cell chunks: library size. Gene chunks: f16 bits of the average
350    /// expression.
351    pub aux: u64,
352    /// Original index of the cell or gene.
353    pub original_index: usize,
354    /// Whether the cell or gene is flagged to keep.
355    pub to_keep: bool,
356    /// [`FLAG_NORM_STORED`] and [`FLAG_RAW_MINUS_ONE`].
357    flags: u8,
358    /// Codec of the body.
359    codec: ChunkCodec,
360}
361
362impl V4Header {
363    /// Parse the header at the start of a chunk.
364    ///
365    /// ### Params
366    ///
367    /// * `chunk` - The chunk bytes, header first
368    ///
369    /// ### Returns
370    ///
371    /// The header, [`ScDataErrors::ChunkBufferTooSmall`] for a chunk shorter
372    /// than the header, or [`ScDataErrors::V4ChunkCorrupt`] for a bad codec.
373    pub(crate) fn parse(chunk: &[u8]) -> Result<Self, ScDataErrors> {
374        if chunk.len() < V4_HEADER_LEN {
375            return Err(ScDataErrors::ChunkBufferTooSmall {
376                expected: V4_HEADER_LEN,
377                found: chunk.len(),
378            });
379        }
380        let u32_at = |at: usize| {
381            u32::from_le_bytes(chunk[at..at + 4].try_into().expect("4-byte slice")) as usize
382        };
383        let u64_at =
384            |at: usize| u64::from_le_bytes(chunk[at..at + 8].try_into().expect("8-byte slice"));
385        Ok(Self {
386            nnz: u32_at(0),
387            body_len: u32_at(4),
388            aux: u64_at(8),
389            original_index: u64_at(16) as usize,
390            to_keep: chunk[24] != 0,
391            flags: chunk[25],
392            codec: ChunkCodec::from_u8(chunk[26])?,
393        })
394    }
395
396    /// Whether the chunk stores its normalised layer verbatim, i.e. the layer
397    /// did not recompute bit for bit from the raw counts.
398    ///
399    /// ### Returns
400    ///
401    /// `true` if [`FLAG_NORM_STORED`] is set.
402    pub(crate) fn norm_stored(&self) -> bool {
403        self.flags & FLAG_NORM_STORED != 0
404    }
405}
406
407////////////
408// Encode //
409////////////
410
411/// Everything [`encode_chunk`] needs about one cell or gene.
412struct EncodeIn<'a> {
413    /// Gene (cell chunks) or cell (gene chunks) indices, ascending.
414    indices: &'a [u32],
415    /// Raw counts.
416    raw: &'a [u32],
417    /// Stored normalised layer, written only when it does not recompute.
418    norm: &'a [F16],
419    /// The header `aux` field.
420    aux: u64,
421    /// Original index of the cell or gene.
422    original_index: usize,
423    /// Keep flag.
424    to_keep: bool,
425    /// Whether the norm layer recomputes bit for bit and can be dropped.
426    norm_derivable: bool,
427}
428
429/// Serialise one chunk into the v4 layout.
430///
431/// Index deltas wrap, so unsorted or duplicated indices stay lossless; they
432/// only cost width.
433///
434/// ### Params
435///
436/// * `p` - The chunk contents
437/// * `codec` - Requested body codec; falls back to none if it does not shrink
438///   the body
439///
440/// ### Returns
441///
442/// Header plus body.
443fn encode_chunk(p: &EncodeIn, codec: ChunkCodec) -> Vec<u8> {
444    let n = p.indices.len();
445
446    let mut deltas = Vec::with_capacity(n);
447    let mut prev = 0u32;
448    for (k, &x) in p.indices.iter().enumerate() {
449        deltas.push(if k == 0 {
450            x
451        } else {
452            x.wrapping_sub(prev).wrapping_sub(1)
453        });
454        prev = x;
455    }
456
457    let mut flags = 0u8;
458    let mut body = Vec::with_capacity(n * 2);
459    pack(&deltas, &mut body);
460
461    if p.raw.iter().all(|&x| x >= 1) {
462        flags |= FLAG_RAW_MINUS_ONE;
463        let minus_one: Vec<u32> = p.raw.iter().map(|&x| x - 1).collect();
464        pack(&minus_one, &mut body);
465    } else {
466        pack(p.raw, &mut body);
467    }
468
469    if !p.norm_derivable {
470        flags |= FLAG_NORM_STORED;
471        for v in p.norm {
472            body.extend_from_slice(&v.to_le_bytes());
473        }
474    }
475
476    let (used, payload) = match codec {
477        ChunkCodec::None => (ChunkCodec::None, None),
478        ChunkCodec::Lz4 => (ChunkCodec::Lz4, Some(lz4_compress(&body))),
479        ChunkCodec::Zstd => (
480            ChunkCodec::Zstd,
481            zstd::bulk::compress(&body, ZSTD_LEVEL).ok(),
482        ),
483    };
484    let (used, payload) = match payload {
485        Some(c) if c.len() < body.len() => (used, c),
486        _ => (ChunkCodec::None, body.clone()),
487    };
488
489    let mut out = Vec::with_capacity(V4_HEADER_LEN + payload.len());
490    out.extend_from_slice(&(n as u32).to_le_bytes());
491    out.extend_from_slice(&(body.len() as u32).to_le_bytes());
492    out.extend_from_slice(&p.aux.to_le_bytes());
493    out.extend_from_slice(&(p.original_index as u64).to_le_bytes());
494    out.extend_from_slice(&[p.to_keep as u8, flags, used.to_u8(), 0, 0, 0, 0, 0]);
495    out.extend_from_slice(&payload);
496    out
497}
498
499impl CsrCellChunk {
500    /// Serialise the cell into a v4 chunk.
501    ///
502    /// The norm layer is dropped when every entry recomputes bit for bit from
503    /// the raw count, the library size and `target_size`.
504    ///
505    /// ### Params
506    ///
507    /// * `target_size` - Target size of the file the cell goes into, `0.0` if
508    ///   unknown, which stores the norm
509    /// * `codec` - Body codec
510    ///
511    /// ### Returns
512    ///
513    /// The chunk bytes, or [`ScDataErrors::V4ChunkCorrupt`] for layers of
514    /// unequal length.
515    pub fn to_v4_bytes(
516        &self,
517        target_size: f32,
518        codec: ChunkCodec,
519    ) -> Result<Vec<u8>, ScDataErrors> {
520        let n = self.indices.len();
521        if self.data_raw.len() != n || self.data_norm.len() != n {
522            return Err(ScDataErrors::V4ChunkCorrupt(format!(
523                "cell {} has layers of unequal length",
524                self.original_index
525            )));
526        }
527        let raw: Vec<u32> = self.data_raw.iter().collect();
528        let lib = self.library_size as f32;
529        let norm_derivable = target_size != 0.0
530            && raw
531                .iter()
532                .zip(&self.data_norm)
533                .all(|(&x, v)| norm_value(x as f32, lib, target_size).to_bits() == v.to_bits());
534
535        Ok(encode_chunk(
536            &EncodeIn {
537                indices: &self.indices,
538                raw: &raw,
539                norm: &self.data_norm,
540                aux: self.library_size as u64,
541                original_index: self.original_index,
542                to_keep: self.to_keep,
543                norm_derivable,
544            },
545            codec,
546        ))
547    }
548}
549
550impl CscGeneChunk {
551    /// Serialise the gene into a v4 chunk.
552    ///
553    /// The norm layer is dropped when every entry recomputes bit for bit from
554    /// the raw count, the library size of its cell and `target_size`.
555    ///
556    /// ### Params
557    ///
558    /// * `cell_lib` - Library size of every cell as `f32`, indexed by the cell
559    ///   indices this chunk stores
560    /// * `target_size` - Target size of the file the gene goes into, `0.0` if
561    ///   unknown, which stores the norm
562    /// * `codec` - Body codec
563    ///
564    /// ### Returns
565    ///
566    /// The chunk bytes, or [`ScDataErrors::V4ChunkCorrupt`] for layers of
567    /// unequal length.
568    pub fn to_v4_bytes(
569        &self,
570        cell_lib: &[f32],
571        target_size: f32,
572        codec: ChunkCodec,
573    ) -> Result<Vec<u8>, ScDataErrors> {
574        let n = self.indices.len();
575        if self.data_raw.len() != n || self.data_norm.len() != n {
576            return Err(ScDataErrors::V4ChunkCorrupt(format!(
577                "gene {} has layers of unequal length",
578                self.original_index
579            )));
580        }
581        let raw: Vec<u32> = self.data_raw.iter().collect();
582        let norm_derivable = target_size != 0.0
583            && self
584                .indices
585                .iter()
586                .zip(&raw)
587                .zip(&self.data_norm)
588                .all(|((&cell, &x), v)| {
589                    cell_lib.get(cell as usize).is_some_and(|&lib| {
590                        norm_value(x as f32, lib, target_size).to_bits() == v.to_bits()
591                    })
592                });
593
594        Ok(encode_chunk(
595            &EncodeIn {
596                indices: &self.indices,
597                raw: &raw,
598                norm: &self.data_norm,
599                aux: self.avg_exp.to_bits() as u64,
600                original_index: self.original_index,
601                to_keep: self.to_keep,
602                norm_derivable,
603            },
604            codec,
605        ))
606    }
607}
608
609////////////
610// Decode //
611////////////
612
613/// Reusable buffers for decoding v4 chunks, one per worker.
614///
615/// `bytes` also serves the v3 path as its decompression scratch, so the fused
616/// readers carry one scratch type for both versions.
617#[derive(Default)]
618pub(crate) struct V4Decoded {
619    /// Decompressed body (v4) or decompressed chunk (v3).
620    pub bytes: Vec<u8>,
621    /// Decoded indices.
622    pub idx: Vec<u32>,
623    /// Decoded raw counts.
624    pub raw: Vec<u32>,
625    /// Decoded normalised layer.
626    pub norm: Vec<f32>,
627}
628
629/// What the decoder needs to know about the file a chunk came from.
630pub(crate) struct V4Ctx<'a> {
631    /// Target size from the file header, `0.0` if unknown.
632    pub target_size: f32,
633    /// Per-cell library sizes as `f32`, present on gene-based v4 files.
634    pub cell_lib: Option<&'a [f32]>,
635    /// Normalised value of small counts per cell, see [`gene_norm_table`].
636    pub cell_norm: Option<&'a [u16]>,
637    /// Whether the chunk is a cell (CSR) chunk.
638    pub is_cell: bool,
639}
640
641/// Decode one v4 chunk into `s`.
642///
643/// Indices are always decoded. Raw counts are decoded when asked for, or when
644/// the norm has to be recomputed from them. The norm is only produced when
645/// asked for.
646///
647/// ### Params
648///
649/// * `chunk` - Header plus body
650/// * `ctx` - File context
651/// * `want_raw` - Whether `s.raw` is needed
652/// * `want_norm` - Whether `s.norm` is needed
653/// * `s` - Scratch the layers are decoded into
654///
655/// ### Returns
656///
657/// The parsed header, or the first corruption error.
658pub(crate) fn decode_v4(
659    chunk: &[u8],
660    ctx: &V4Ctx,
661    want_raw: bool,
662    want_norm: bool,
663    s: &mut V4Decoded,
664) -> Result<V4Header, ScDataErrors> {
665    let V4Decoded {
666        bytes,
667        idx,
668        raw,
669        norm,
670    } = s;
671    idx.clear();
672    norm.clear();
673    decode_v4_into(
674        chunk,
675        ctx,
676        want_raw,
677        bytes,
678        raw,
679        idx,
680        want_norm.then_some(norm),
681    )
682}
683
684/// Decode one v4 chunk, appending indices and norm to caller-owned vectors.
685///
686/// The fused readers hand in the vectors of their output fragment, so an
687/// unfiltered run is decoded straight into place without an intermediate copy.
688/// Raw counts land in `raw`, which is overwritten: they still need a typed
689/// conversion by the caller.
690///
691/// ### Params
692///
693/// * `chunk` - Header plus body
694/// * `ctx` - File context
695/// * `want_raw` - Whether `raw` is needed
696/// * `bytes` - Scratch for the decompressed body
697/// * `raw` - Receives the raw counts, cleared first
698/// * `idx` - Indices are appended
699/// * `norm` - The normalised layer is appended when `Some`
700///
701/// ### Returns
702///
703/// The parsed header, or the first corruption error. On error `idx` and
704/// `norm` may hold a partial run.
705pub(crate) fn decode_v4_into(
706    chunk: &[u8],
707    ctx: &V4Ctx,
708    want_raw: bool,
709    bytes: &mut Vec<u8>,
710    raw: &mut Vec<u32>,
711    idx: &mut Vec<u32>,
712    norm: Option<&mut Vec<f32>>,
713) -> Result<V4Header, ScDataErrors> {
714    let h = V4Header::parse(chunk)?;
715
716    let stored = &chunk[V4_HEADER_LEN..];
717    let body: &[u8] = match h.codec {
718        ChunkCodec::None => stored,
719        ChunkCodec::Lz4 => {
720            bytes.resize(h.body_len, 0);
721            let n = lz4_decompress_into(stored, bytes)
722                .map_err(|e| ScDataErrors::V4ChunkCorrupt(format!("lz4: {e}")))?;
723            &bytes[..n]
724        }
725        ChunkCodec::Zstd => {
726            bytes.resize(h.body_len, 0);
727            let n = zstd::bulk::decompress_to_buffer(stored, &mut bytes[..])
728                .map_err(|e| ScDataErrors::V4ChunkCorrupt(format!("zstd: {e}")))?;
729            &bytes[..n]
730        }
731    };
732
733    let n = h.nnz;
734    let start = idx.len();
735    let used = unpack(body, n, idx)?;
736    let mut prev = 0u32;
737    for (k, v) in idx[start..].iter_mut().enumerate() {
738        if k > 0 {
739            *v = prev.wrapping_add(*v).wrapping_add(1);
740        }
741        prev = *v;
742    }
743
744    let norm_stored = h.flags & FLAG_NORM_STORED != 0;
745    let want_norm = norm.is_some();
746    let need_raw = want_raw || (want_norm && !norm_stored);
747    let rest = &body[used..];
748
749    let raw_len = if need_raw {
750        raw.clear();
751        let len = unpack(rest, n, raw)?;
752        if h.flags & FLAG_RAW_MINUS_ONE != 0 {
753            raw.iter_mut().for_each(|x| *x = x.wrapping_add(1));
754        }
755        len
756    } else if want_norm {
757        packed_len(rest, n)?
758    } else {
759        0
760    };
761
762    let Some(norm) = norm else {
763        return Ok(h);
764    };
765
766    norm.reserve(n);
767    if norm_stored {
768        let stored_norm = rest.get(raw_len..raw_len + n * 2).ok_or_else(truncated)?;
769        norm.extend(
770            stored_norm
771                .chunks_exact(2)
772                .map(|c| F16::from_le_bytes([c[0], c[1]]).to_f32()),
773        );
774    } else if ctx.is_cell {
775        let lib = h.aux as f32;
776        let top = raw.iter().copied().max().unwrap_or(0) as usize;
777        let lut: Vec<f32> = (0..=top.min(NORM_LUT_MAX))
778            .map(|x| norm_value(x as f32, lib, ctx.target_size).to_f32())
779            .collect();
780        norm.extend(raw.iter().map(|&x| match lut.get(x as usize) {
781            Some(&v) => v,
782            None => norm_value(x as f32, lib, ctx.target_size).to_f32(),
783        }));
784    } else {
785        // an empty run has nothing to recompute and needs no table
786        let cell_lib = match ctx.cell_lib {
787            Some(table) => table,
788            None if n == 0 => &[],
789            None => return Err(ScDataErrors::V4MissingCellLibraryTable),
790        };
791        // an out-of-table cell is flagged and reported once after the loop,
792        // which keeps the hot closure free of an early return
793        let mut outside = None;
794        let table = ctx.cell_norm.unwrap_or(&[]);
795        norm.extend(idx[start..].iter().zip(raw.iter()).map(|(&cell, &x)| {
796            let slot = (x as usize).wrapping_sub(1);
797            if slot < GENE_NORM_LUT_COUNTS
798                && let Some(&bits) = table.get(cell as usize * GENE_NORM_LUT_COUNTS + slot)
799            {
800                return F16::from_bits(bits).to_f32();
801            }
802            match cell_lib.get(cell as usize) {
803                Some(&lib) => norm_value(x as f32, lib, ctx.target_size).to_f32(),
804                None => {
805                    outside = Some(cell);
806                    0.0
807                }
808            }
809        }));
810        if let Some(cell) = outside {
811            return Err(ScDataErrors::V4ChunkCorrupt(format!(
812                "cell {cell} outside the library table"
813            )));
814        }
815    }
816
817    Ok(h)
818}
819
820/// Per-cell table of the normalised value of counts `1..=GENE_NORM_LUT_COUNTS`.
821///
822/// Gene runs normalise each entry against a different cell, so there is no
823/// per-run lookup table as in the cell view. Nearly every UMI count is small,
824/// so a flat table indexed by `cell * GENE_NORM_LUT_COUNTS + count - 1` turns
825/// the per-entry `ln_1p` into a load. Built once when the file is opened, with
826/// the same [`norm_value`] the encoder verified against, so values stay bit
827/// identical.
828///
829/// ### Params
830///
831/// * `cell_lib` - Library size per cell as `f32`
832/// * `target_size` - Target size from the file header
833///
834/// ### Returns
835///
836/// f16 bits, `cell_lib.len() * GENE_NORM_LUT_COUNTS` long.
837pub(crate) fn gene_norm_table(cell_lib: &[f32], target_size: f32) -> Vec<u16> {
838    let mut table = vec![0u16; cell_lib.len() * GENE_NORM_LUT_COUNTS];
839    table
840        .par_chunks_mut(GENE_NORM_LUT_COUNTS)
841        .zip(cell_lib.par_iter())
842        .for_each(|(row, &lib)| {
843            for (k, v) in row.iter_mut().enumerate() {
844                *v = norm_value((k + 1) as f32, lib, target_size).to_bits();
845            }
846        });
847    table
848}
849
850/// Borrowed view of a decoded v4 run, for the fused readers.
851pub(crate) struct V4Run<'a> {
852    /// Decoded indices.
853    idx: &'a [u32],
854    /// Decoded raw counts, stale when raw was not requested.
855    raw: &'a [u32],
856    /// Decoded normalised layer, stale when norm was not requested.
857    norm: &'a [f32],
858}
859
860impl<'a> V4Run<'a> {
861    /// View the layers a [`decode_v4`] call left in `s`.
862    ///
863    /// ### Params
864    ///
865    /// * `s` - The scratch
866    ///
867    /// ### Returns
868    ///
869    /// The view.
870    pub(crate) fn new(s: &'a V4Decoded) -> Self {
871        Self {
872            idx: &s.idx,
873            raw: &s.raw,
874            norm: &s.norm,
875        }
876    }
877}
878
879impl RunSource for V4Run<'_> {
880    #[inline]
881    fn nnz(&self) -> usize {
882        self.idx.len()
883    }
884
885    #[inline(always)]
886    fn index_at(&self, i: usize) -> u32 {
887        self.idx[i]
888    }
889
890    #[inline(always)]
891    fn raw_at(&self, i: usize) -> u32 {
892        self.raw[i]
893    }
894
895    #[inline(always)]
896    fn norm_at(&self, i: usize) -> f32 {
897        self.norm[i]
898    }
899
900    fn extend_indices(&self, out: &mut Vec<u32>) {
901        out.extend_from_slice(self.idx);
902    }
903
904    fn extend_raw<T: num_traits::FromPrimitive>(
905        &self,
906        out: &mut Vec<T>,
907    ) -> Result<(), ScDataErrors> {
908        extend_raw_checked(out, self.raw[..self.idx.len()].iter().copied())
909    }
910
911    fn extend_norm(&self, out: &mut Vec<f32>) {
912        out.extend_from_slice(&self.norm[..self.idx.len()]);
913    }
914}
915
916/// Decode a v4 cell chunk into an owned [`CsrCellChunk`].
917///
918/// ### Params
919///
920/// * `chunk` - Header plus body
921/// * `ctx` - File context
922/// * `s` - Scratch
923///
924/// ### Returns
925///
926/// The cell, with both layers.
927pub(crate) fn v4_cell_chunk(
928    chunk: &[u8],
929    ctx: &V4Ctx,
930    s: &mut V4Decoded,
931) -> Result<CsrCellChunk, ScDataErrors> {
932    let h = decode_v4(chunk, ctx, true, true, s)?;
933    Ok(CsrCellChunk {
934        data_raw: RawCounts::from_u32_auto(&s.raw),
935        data_norm: s.norm.iter().map(|&x| F16::from_f32(x)).collect(),
936        library_size: h.aux as usize,
937        indices: s.idx.clone(),
938        original_index: h.original_index,
939        to_keep: h.to_keep,
940    })
941}
942
943/// Decode a v4 gene chunk into an owned [`CscGeneChunk`].
944///
945/// ### Params
946///
947/// * `chunk` - Header plus body
948/// * `ctx` - File context
949/// * `s` - Scratch
950///
951/// ### Returns
952///
953/// The gene, with both layers.
954pub(crate) fn v4_gene_chunk(
955    chunk: &[u8],
956    ctx: &V4Ctx,
957    s: &mut V4Decoded,
958) -> Result<CscGeneChunk, ScDataErrors> {
959    let h = decode_v4(chunk, ctx, true, true, s)?;
960    Ok(CscGeneChunk {
961        data_raw: RawCounts::from_u32_auto(&s.raw),
962        data_norm: s.norm.iter().map(|&x| F16::from_f32(x)).collect(),
963        avg_exp: F16::from_bits(h.aux as u16),
964        nnz: h.nnz,
965        indices: s.idx.clone(),
966        original_index: h.original_index,
967        to_keep: h.to_keep,
968    })
969}
970
971/////////////////
972// ChunkFormat //
973/////////////////
974
975/// How a writer encodes the chunks it appends.
976///
977/// Copied out of the writer so parallel payload builders can encode without
978/// borrowing it.
979#[derive(Clone, Copy, Debug)]
980pub struct ChunkFormat {
981    /// File version: [`SC_FILE_VERSION`] or [`SC_FILE_VERSION_V3`].
982    pub version: u32,
983    /// Body codec of v4 chunks. Unused by v3.
984    pub codec: ChunkCodec,
985    /// Target size in the file header, which readers recompute the norm layer
986    /// with. `0.0` stores the norm layer verbatim.
987    pub target_size: f32,
988}
989
990impl ChunkFormat {
991    /// Whether this format is the current (v4) one.
992    ///
993    /// ### Returns
994    ///
995    /// `true` for v4.
996    pub fn is_v4(&self) -> bool {
997        self.version == SC_FILE_VERSION
998    }
999
1000    /// Encode a cell chunk for a file of this format.
1001    ///
1002    /// ### Params
1003    ///
1004    /// * `chunk` - The cell
1005    ///
1006    /// ### Returns
1007    ///
1008    /// The payload the writer's `write_compressed_cell_chunks` expects.
1009    pub fn encode_cell(&self, chunk: &CsrCellChunk) -> Result<Vec<u8>, ScDataErrors> {
1010        if self.is_v4() {
1011            chunk.to_v4_bytes(self.target_size, self.codec)
1012        } else {
1013            Ok(chunk.to_compressed_bytes()?)
1014        }
1015    }
1016
1017    /// Encode a gene chunk for a file of this format.
1018    ///
1019    /// ### Params
1020    ///
1021    /// * `chunk` - The gene
1022    /// * `cell_lib` - Library size per cell as `f32`, the table the file will
1023    ///   carry. Empty stores the norm layer verbatim.
1024    ///
1025    /// ### Returns
1026    ///
1027    /// The payload the writer's `write_compressed_gene_chunks` expects.
1028    pub fn encode_gene(
1029        &self,
1030        chunk: &CscGeneChunk,
1031        cell_lib: &[f32],
1032    ) -> Result<Vec<u8>, ScDataErrors> {
1033        if self.is_v4() {
1034            chunk.to_v4_bytes(cell_lib, self.target_size, self.codec)
1035        } else {
1036            Ok(chunk.to_compressed_bytes()?)
1037        }
1038    }
1039
1040    /// Encode a v4 gene chunk whose norm layer is known to recompute.
1041    ///
1042    /// Same bytes as [`Self::encode_gene`] on a kept gene whose every entry
1043    /// passes the derivability check, without carrying or re-checking the
1044    /// layer. The caller guarantees that, e.g. by deriving `avg_exp` from the
1045    /// same [`norm_value`] the reader recomputes with.
1046    ///
1047    /// ### Params
1048    ///
1049    /// * `indices` - Cell indices, ascending
1050    /// * `raw` - Raw counts, same length
1051    /// * `avg_exp` - Sum of the normalised layer, the header `aux`
1052    /// * `original_index` - Gene index
1053    ///
1054    /// ### Returns
1055    ///
1056    /// The payload the writer's `write_compressed_gene_chunks` expects.
1057    pub(crate) fn encode_gene_derivable(
1058        &self,
1059        indices: &[u32],
1060        raw: &[u32],
1061        avg_exp: F16,
1062        original_index: usize,
1063    ) -> Vec<u8> {
1064        debug_assert!(self.is_v4() && indices.len() == raw.len());
1065        encode_chunk(
1066            &EncodeIn {
1067                indices,
1068                raw,
1069                norm: &[],
1070                aux: avg_exp.to_bits() as u64,
1071                original_index,
1072                to_keep: true,
1073                norm_derivable: true,
1074            },
1075            self.codec,
1076        )
1077    }
1078}
1079
1080////////////////
1081// Converters //
1082////////////////
1083
1084/// Inverse of the index map: original index per chunk, in file order.
1085///
1086/// ### Params
1087///
1088/// * `header` - The store header
1089///
1090/// ### Returns
1091///
1092/// `order[chunk] = original index`, or [`ScDataErrors::V4ChunkCorrupt`] for a
1093/// map that points outside the file.
1094fn chunk_order(header: &SparseDataHeader) -> Result<Vec<usize>, ScDataErrors> {
1095    let mut order = vec![usize::MAX; header.no_chunks];
1096    for (&orig, &chunk) in &header.index_map {
1097        *order.get_mut(chunk).ok_or_else(|| {
1098            ScDataErrors::V4ChunkCorrupt(format!("chunk {chunk} outside the file"))
1099        })? = orig;
1100    }
1101    Ok(order)
1102}
1103
1104/// Refuse anything but a v3 input.
1105///
1106/// ### Params
1107///
1108/// * `reader` - The opened input
1109///
1110/// ### Returns
1111///
1112/// `Ok(())` for v3, [`ScDataErrors::FileVersionMismatch`] otherwise.
1113fn require_v3(reader: &ParallelSparseReader) -> Result<(), ScDataErrors> {
1114    if reader.file_version() != SC_FILE_VERSION_V3 {
1115        return Err(ScDataErrors::FileVersionMismatch {
1116            expected: SC_FILE_VERSION_V3,
1117            found: reader.file_version(),
1118        });
1119    }
1120    Ok(())
1121}
1122
1123/// Convert a v3 cell-based file to v4.
1124///
1125/// Streams in batches of [`CONVERT_CELL_BATCH`] chunks, in file order, so the
1126/// index map of the output equals the input's.
1127///
1128/// ### Params
1129///
1130/// * `cell_in` - Path to the v3 cell-based file
1131/// * `cell_out` - Path of the v4 file to write
1132/// * `codec` - Body codec of the output
1133/// * `verbose` - Controls verbosity
1134///
1135/// ### Returns
1136///
1137/// Library sizes indexed by original cell index, `total_cells` long at least,
1138/// which [`convert_gene_file_to_v4`] needs to drop the gene-file norm.
1139pub fn convert_cell_file_to_v4(
1140    cell_in: &str,
1141    cell_out: &str,
1142    codec: ChunkCodec,
1143    verbose: bool,
1144) -> Result<Vec<u64>, ScDataErrors> {
1145    let start = Instant::now();
1146    let reader = ParallelSparseReader::new(cell_in)?;
1147    require_v3(&reader)?;
1148    if !reader.is_cell_based() {
1149        return Err(ScDataErrors::ReaderModeMismatch {
1150            actual: "gene-based",
1151            requested: "cell-based",
1152        });
1153    }
1154    let header = reader.get_header();
1155    let target_size = reader.target_size().unwrap_or(0.0);
1156    let order = chunk_order(&header)?;
1157
1158    let mut writer = CellGeneSparseWriter::new(
1159        cell_out,
1160        true,
1161        header.total_cells,
1162        header.total_genes,
1163        target_size,
1164    )?;
1165
1166    let table_len = header
1167        .total_cells
1168        .max(order.iter().copied().max().map_or(0, |m| m + 1));
1169    let mut lib_table = vec![0u64; table_len];
1170    let mut stored_norm = 0usize;
1171
1172    for batch in order.chunks(CONVERT_CELL_BATCH) {
1173        let cells = reader.read_cells_parallel(batch)?;
1174        let payloads = cells
1175            .par_iter()
1176            .map(|cell| {
1177                Ok(CellPayload {
1178                    original_index: cell.original_index,
1179                    library_size: cell.library_size,
1180                    bytes: cell.to_v4_bytes(target_size, codec)?,
1181                })
1182            })
1183            .collect::<Result<Vec<_>, ScDataErrors>>()?;
1184        for p in &payloads {
1185            lib_table[p.original_index] = p.library_size as u64;
1186            stored_norm += (V4Header::parse(&p.bytes)?.flags & FLAG_NORM_STORED != 0) as usize;
1187        }
1188        writer.write_compressed_cell_chunks(&payloads)?;
1189    }
1190    writer.finalise()?;
1191
1192    if verbose {
1193        println!(
1194            "  Converted {} cells to v4 ({} with stored norm) in {:.2?}.",
1195            header.no_chunks.separate_with_underscores(),
1196            stored_norm.separate_with_underscores(),
1197            start.elapsed()
1198        );
1199    }
1200
1201    Ok(lib_table)
1202}
1203
1204/// Convert a v3 gene-based file to v4.
1205///
1206/// Streams in batches of [`CONVERT_GENE_BATCH`] chunks, in file order. The
1207/// output carries `cell_lib` as its library-size table.
1208///
1209/// ### Params
1210///
1211/// * `gene_in` - Path to the v3 gene-based file
1212/// * `gene_out` - Path of the v4 file to write
1213/// * `cell_lib` - Library sizes indexed by original cell index, as returned by
1214///   [`convert_cell_file_to_v4`]
1215/// * `codec` - Body codec of the output
1216/// * `verbose` - Controls verbosity
1217///
1218/// ### Returns
1219///
1220/// `Ok(())` once the output is finalised.
1221pub fn convert_gene_file_to_v4(
1222    gene_in: &str,
1223    gene_out: &str,
1224    cell_lib: &[u64],
1225    codec: ChunkCodec,
1226    verbose: bool,
1227) -> Result<(), ScDataErrors> {
1228    let start = Instant::now();
1229    let reader = ParallelSparseReader::new(gene_in)?;
1230    require_v3(&reader)?;
1231    if !reader.is_gene_based() {
1232        return Err(ScDataErrors::ReaderModeMismatch {
1233            actual: "cell-based",
1234            requested: "gene-based",
1235        });
1236    }
1237    let header = reader.get_header();
1238    let target_size = reader.target_size().unwrap_or(0.0);
1239    let order = chunk_order(&header)?;
1240    let lib_f32: Vec<f32> = cell_lib.iter().map(|&l| l as f32).collect();
1241
1242    let mut writer = CellGeneSparseWriter::new(
1243        gene_out,
1244        false,
1245        header.total_cells,
1246        header.total_genes,
1247        target_size,
1248    )?;
1249
1250    let mut stored_norm = 0usize;
1251    for batch in order.chunks(CONVERT_GENE_BATCH) {
1252        let genes = reader.read_gene_parallel(batch)?;
1253        let payloads = genes
1254            .par_iter()
1255            .map(|gene| {
1256                Ok((
1257                    gene.original_index,
1258                    gene.to_v4_bytes(&lib_f32, target_size, codec)?,
1259                ))
1260            })
1261            .collect::<Result<Vec<_>, ScDataErrors>>()?;
1262        for (_, bytes) in &payloads {
1263            stored_norm += (V4Header::parse(bytes)?.flags & FLAG_NORM_STORED != 0) as usize;
1264        }
1265        writer.write_compressed_gene_chunks(&payloads)?;
1266    }
1267    writer.set_cell_library_table(cell_lib.to_vec())?;
1268    writer.finalise()?;
1269
1270    if verbose {
1271        println!(
1272            "  Converted {} genes to v4 ({} with stored norm) in {:.2?}.",
1273            header.no_chunks.separate_with_underscores(),
1274            stored_norm.separate_with_underscores(),
1275            start.elapsed()
1276        );
1277    }
1278
1279    Ok(())
1280}
1281
1282/// Convert a v3 store to v4: the cell file, and the gene file when given.
1283///
1284/// Inputs are never modified. Neither output may equal an input path.
1285///
1286/// ### Params
1287///
1288/// * `cell_in` - Path to the v3 cell-based file
1289/// * `gene_in` - Path to the v3 gene-based file, if any
1290/// * `cell_out` - Path of the v4 cell file
1291/// * `gene_out` - Path of the v4 gene file, required with `gene_in`
1292/// * `codec` - Body codec of both outputs
1293/// * `verbose` - Controls verbosity
1294///
1295/// ### Returns
1296///
1297/// `Ok(())` once both outputs are finalised.
1298pub fn convert_store_v3_to_v4(
1299    cell_in: &str,
1300    gene_in: Option<&str>,
1301    cell_out: &str,
1302    gene_out: Option<&str>,
1303    codec: ChunkCodec,
1304    verbose: bool,
1305) -> Result<(), ScDataErrors> {
1306    let lib = convert_cell_file_to_v4(cell_in, cell_out, codec, verbose)?;
1307    if let (Some(gene_in), Some(gene_out)) = (gene_in, gene_out) {
1308        convert_gene_file_to_v4(gene_in, gene_out, &lib, codec, verbose)?;
1309    }
1310    Ok(())
1311}
1312
1313/// Replace a v3 store by its v4 conversion, in place.
1314///
1315/// Converts into `<path>.v4.tmp` next to each file first and only touches the
1316/// originals once every conversion has succeeded, so a failure leaves the
1317/// store as it was. Each original is then renamed to `<path>.v3.bak` when
1318/// `keep_backup` is set, and deleted otherwise.
1319///
1320/// ### Params
1321///
1322/// * `cell_path` - Path to the v3 cell-based file
1323/// * `gene_path` - Path to the v3 gene-based file, if any
1324/// * `codec` - Body codec of the converted files
1325/// * `keep_backup` - Keep the v3 originals as `<path>.v3.bak`
1326/// * `verbose` - Controls verbosity
1327///
1328/// ### Returns
1329///
1330/// `Ok(())` once the converted files are in place.
1331pub fn upgrade_store_to_v4(
1332    cell_path: &str,
1333    gene_path: Option<&str>,
1334    codec: ChunkCodec,
1335    keep_backup: bool,
1336    verbose: bool,
1337) -> Result<(), ScDataErrors> {
1338    let tmp = |p: &str| format!("{p}.v4.tmp");
1339    let cleanup = |paths: &[Option<&str>]| {
1340        for p in paths.iter().flatten() {
1341            let _ = std::fs::remove_file(tmp(p));
1342        }
1343    };
1344
1345    let result = (|| {
1346        let cell_tmp = tmp(cell_path);
1347        let lib = convert_cell_file_to_v4(cell_path, &cell_tmp, codec, verbose)?;
1348        if let Some(gene_path) = gene_path {
1349            convert_gene_file_to_v4(gene_path, &tmp(gene_path), &lib, codec, verbose)?;
1350        }
1351        // the converted files must open and agree with the originals
1352        for p in std::iter::once(cell_path).chain(gene_path) {
1353            let (old, new) = (
1354                ParallelSparseReader::new(p)?.get_header(),
1355                ParallelSparseReader::new(&tmp(p))?.get_header(),
1356            );
1357            if (old.total_cells, old.total_genes, old.no_chunks)
1358                != (new.total_cells, new.total_genes, new.no_chunks)
1359            {
1360                return Err(ScDataErrors::V4ChunkCorrupt(format!(
1361                    "converted {p} does not match the original"
1362                )));
1363            }
1364        }
1365        Ok(())
1366    })();
1367    if let Err(e) = result {
1368        cleanup(&[Some(cell_path), gene_path]);
1369        return Err(e);
1370    }
1371
1372    for p in std::iter::once(cell_path).chain(gene_path) {
1373        if keep_backup {
1374            std::fs::rename(p, format!("{p}.v3.bak"))?;
1375        }
1376        std::fs::rename(tmp(p), p)?;
1377    }
1378    Ok(())
1379}
1380
1381///////////
1382// Tests //
1383///////////
1384
1385#[cfg(test)]
1386mod tests {
1387    use super::*;
1388    use indexmap::IndexSet;
1389
1390    #[test]
1391    fn test_pack_unpack_round_trip_widths() {
1392        for &(n, max) in &[
1393            (0usize, 0u32),
1394            (1, 5),
1395            (127, 1),
1396            (128, 255),
1397            (129, 70_000),
1398            (300, 0),
1399            (500, u32::MAX),
1400        ] {
1401            let vals: Vec<u32> = (0..n)
1402                .map(|i| ((i as u64 * 2_654_435_761) % (max as u64 + 1)) as u32)
1403                .collect();
1404            let mut buf = Vec::new();
1405            pack(&vals, &mut buf);
1406            let mut out = Vec::new();
1407            let used = unpack(&buf, n, &mut out).unwrap();
1408            assert_eq!(out, vals, "n {n} max {max}");
1409            assert_eq!(used, buf.len());
1410            assert_eq!(packed_len(&buf, n).unwrap(), buf.len());
1411        }
1412    }
1413
1414    #[test]
1415    fn test_unpack_every_width_through_the_specialised_path() {
1416        for width in 1..=32u32 {
1417            let max = if width == 32 {
1418                u32::MAX
1419            } else {
1420                (1u32 << width) - 1
1421            };
1422            let vals: Vec<u32> = (0..PACK_BLOCK * 3 + 5)
1423                .map(|i| {
1424                    if i % 128 == 0 {
1425                        max
1426                    } else {
1427                        (i as u32).wrapping_mul(2_654_435_761) & max
1428                    }
1429                })
1430                .collect();
1431            let mut buf = Vec::new();
1432            pack(&vals, &mut buf);
1433            let mut out = Vec::new();
1434            assert_eq!(unpack(&buf, vals.len(), &mut out).unwrap(), buf.len());
1435            assert_eq!(out, vals, "width {width}");
1436        }
1437    }
1438
1439    #[test]
1440    fn test_unpack_rejects_truncated_and_wide() {
1441        let mut buf = Vec::new();
1442        pack(&[1, 2, 3, 400], &mut buf);
1443        let mut out = Vec::new();
1444        assert!(unpack(&buf[..buf.len() - 1], 4, &mut out).is_err());
1445        assert!(unpack(&[33], 1, &mut out).is_err());
1446    }
1447
1448    /// Removes the files of a test on drop.
1449    struct Scratch(Vec<std::path::PathBuf>);
1450
1451    impl Scratch {
1452        fn new(tag: &str, n: usize) -> Self {
1453            let dir = std::env::temp_dir();
1454            Self(
1455                (0..n)
1456                    .map(|i| dir.join(format!("bx_v4_{tag}_{}_{i}.bin", std::process::id())))
1457                    .collect(),
1458            )
1459        }
1460
1461        fn path(&self, i: usize) -> &str {
1462            self.0[i].to_str().unwrap()
1463        }
1464    }
1465
1466    impl Drop for Scratch {
1467        fn drop(&mut self) {
1468            for p in &self.0 {
1469                let _ = std::fs::remove_file(p);
1470            }
1471        }
1472    }
1473
1474    const N_CELLS: usize = 300;
1475    const N_GENES: usize = 40;
1476    const TARGET: f32 = 1e4;
1477
1478    /// Cells with a spread of counts, one empty cell, one with a count above
1479    /// every small width, and one whose stored norm does not recompute.
1480    fn synthetic_cells() -> Vec<CsrCellChunk> {
1481        let mut state = 12345u64;
1482        let mut next = move || {
1483            state = state
1484                .wrapping_mul(6364136223846793005)
1485                .wrapping_add(1442695040888963407);
1486            (state >> 33) as u32
1487        };
1488        (0..N_CELLS)
1489            .map(|c| {
1490                let mut genes = Vec::new();
1491                let mut counts = Vec::new();
1492                if c != 7 {
1493                    for g in 0..N_GENES as u32 {
1494                        if next() % 3 == 0 {
1495                            genes.push(g);
1496                            counts.push(1 + next() % 4 + if next() % 50 == 0 { 300 } else { 0 });
1497                        }
1498                    }
1499                }
1500                if c == 11 {
1501                    match genes.last() {
1502                        Some(&g) if g == N_GENES as u32 - 1 => *counts.last_mut().unwrap() = 70_000,
1503                        _ => {
1504                            genes.push(N_GENES as u32 - 1);
1505                            counts.push(70_000);
1506                        }
1507                    }
1508                }
1509                let mut chunk = CsrCellChunk::from_data(&counts, &genes, c, TARGET, true);
1510                if c == 13 && !chunk.data_norm.is_empty() {
1511                    chunk.data_norm[0] = F16::from_f32(chunk.data_norm[0].to_f32() + 0.5);
1512                }
1513                chunk
1514            })
1515            .collect()
1516    }
1517
1518    fn write_v3_store(cell_path: &str, gene_path: &str, target: f32) {
1519        let mut writer =
1520            CellGeneSparseWriter::new_v3(cell_path, true, N_CELLS, N_GENES, target).unwrap();
1521        for chunk in synthetic_cells() {
1522            writer.write_cell_chunk(chunk).unwrap();
1523        }
1524        writer.finalise().unwrap();
1525        crate::gene_file_io::write_gene_file_v3(cell_path, gene_path, Some(500), false).unwrap();
1526    }
1527
1528    fn assert_cells_equal(a: &CsrCellChunk, b: &CsrCellChunk) {
1529        assert_eq!(a.original_index, b.original_index);
1530        assert_eq!(a.library_size, b.library_size);
1531        assert_eq!(a.to_keep, b.to_keep);
1532        assert_eq!(a.indices, b.indices);
1533        assert_eq!(
1534            a.data_raw.iter().collect::<Vec<_>>(),
1535            b.data_raw.iter().collect::<Vec<_>>()
1536        );
1537        let bits = |c: &CsrCellChunk| c.data_norm.iter().map(|v| v.to_bits()).collect::<Vec<_>>();
1538        assert_eq!(bits(a), bits(b));
1539    }
1540
1541    fn assert_genes_equal(a: &CscGeneChunk, b: &CscGeneChunk) {
1542        assert_eq!(a.original_index, b.original_index);
1543        assert_eq!(a.nnz, b.nnz);
1544        assert_eq!(a.avg_exp.to_bits(), b.avg_exp.to_bits());
1545        assert_eq!(a.indices, b.indices);
1546        assert_eq!(
1547            a.data_raw.iter().collect::<Vec<_>>(),
1548            b.data_raw.iter().collect::<Vec<_>>()
1549        );
1550        let bits = |c: &CscGeneChunk| c.data_norm.iter().map(|v| v.to_bits()).collect::<Vec<_>>();
1551        assert_eq!(bits(a), bits(b));
1552    }
1553
1554    fn assert_sparse_equal(
1555        a: &CompressedSparseData2<f32, f32>,
1556        b: &CompressedSparseData2<f32, f32>,
1557    ) {
1558        assert_eq!(a.shape, b.shape);
1559        assert_eq!(a.indptr, b.indptr);
1560        assert_eq!(a.indices, b.indices);
1561        assert_eq!(a.data, b.data);
1562        assert_eq!(a.data_2, b.data_2);
1563    }
1564
1565    /// Every read path of a converted store matches the v3 original, for each
1566    /// codec and with a zero target size (norm always stored).
1567    fn check_conversion(codec: ChunkCodec, target: f32) {
1568        let tag = format!("{codec:?}_{}", target as u32);
1569        let s = Scratch::new(&tag, 4);
1570        write_v3_store(s.path(0), s.path(1), target);
1571        convert_store_v3_to_v4(
1572            s.path(0),
1573            Some(s.path(1)),
1574            s.path(2),
1575            Some(s.path(3)),
1576            codec,
1577            false,
1578        )
1579        .unwrap();
1580
1581        let (cells3, cells4) = (
1582            ParallelSparseReader::new(s.path(0)).unwrap(),
1583            ParallelSparseReader::new(s.path(2)).unwrap(),
1584        );
1585        let (genes3, genes4) = (
1586            ParallelSparseReader::new(s.path(1)).unwrap(),
1587            ParallelSparseReader::new(s.path(3)).unwrap(),
1588        );
1589        assert_eq!(cells3.file_version(), SC_FILE_VERSION_V3);
1590        assert_eq!(cells4.file_version(), SC_FILE_VERSION);
1591        assert_eq!(genes4.file_version(), SC_FILE_VERSION);
1592        assert_eq!(cells4.target_size(), cells3.target_size());
1593
1594        let all_cells: Vec<usize> = (0..N_CELLS).collect();
1595        let all_genes: Vec<usize> = (0..N_GENES).collect();
1596        for (a, b) in cells3
1597            .read_cells_parallel(&all_cells)
1598            .unwrap()
1599            .iter()
1600            .zip(&cells4.read_cells_parallel(&all_cells).unwrap())
1601        {
1602            assert_cells_equal(a, b);
1603        }
1604        for (a, b) in genes3
1605            .read_gene_parallel(&all_genes)
1606            .unwrap()
1607            .iter()
1608            .zip(&genes4.read_gene_parallel(&all_genes).unwrap())
1609        {
1610            assert_genes_equal(a, b);
1611        }
1612
1613        let shuffled: Vec<usize> = (0..N_CELLS).rev().step_by(3).collect();
1614        let keep: IndexSet<u32> = (0..N_CELLS as u32).filter(|c| c % 4 != 1).collect();
1615        for layer in [
1616            DataLayerReturn::Raw,
1617            DataLayerReturn::Norm,
1618            DataLayerReturn::BothLayers,
1619        ] {
1620            assert_sparse_equal(
1621                &cells3.read_cells_sparse::<f32>(&shuffled, &layer).unwrap(),
1622                &cells4.read_cells_sparse::<f32>(&shuffled, &layer).unwrap(),
1623            );
1624            for subset in [None, Some(&keep)] {
1625                assert_sparse_equal(
1626                    &genes3
1627                        .read_genes_sparse::<f32>(&all_genes, subset, &layer)
1628                        .unwrap(),
1629                    &genes4
1630                        .read_genes_sparse::<f32>(&all_genes, subset, &layer)
1631                        .unwrap(),
1632                );
1633            }
1634        }
1635
1636        for (a, b) in genes3
1637            .read_gene_parallel_filtered(&all_genes, &keep)
1638            .unwrap()
1639            .iter()
1640            .zip(
1641                &genes4
1642                    .read_gene_parallel_filtered(&all_genes, &keep)
1643                    .unwrap(),
1644            )
1645        {
1646            assert_genes_equal(a, b);
1647        }
1648        assert_eq!(
1649            cells3.read_cell_library_sizes(&all_cells).unwrap(),
1650            cells4.read_cell_library_sizes(&all_cells).unwrap()
1651        );
1652        assert_eq!(
1653            genes3.read_gene_nnz(&all_genes).unwrap(),
1654            genes4.read_gene_nnz(&all_genes).unwrap()
1655        );
1656        assert_eq!(
1657            cells3.get_header().chunk_offsets.len(),
1658            cells4.get_header().chunk_offsets.len()
1659        );
1660    }
1661
1662    #[test]
1663    fn test_v3_to_v4_round_trip_none() {
1664        check_conversion(ChunkCodec::None, TARGET);
1665    }
1666
1667    #[test]
1668    fn test_v3_to_v4_round_trip_lz4() {
1669        check_conversion(ChunkCodec::Lz4, TARGET);
1670    }
1671
1672    #[test]
1673    fn test_v3_to_v4_round_trip_zstd() {
1674        check_conversion(ChunkCodec::Zstd, TARGET);
1675    }
1676
1677    #[test]
1678    fn test_v3_to_v4_round_trip_unknown_target_stores_norm() {
1679        check_conversion(ChunkCodec::Lz4, 0.0);
1680    }
1681
1682    #[test]
1683    fn test_v4_drops_norm_and_shrinks() {
1684        let s = Scratch::new("shrink", 4);
1685        write_v3_store(s.path(0), s.path(1), TARGET);
1686        convert_store_v3_to_v4(
1687            s.path(0),
1688            Some(s.path(1)),
1689            s.path(2),
1690            Some(s.path(3)),
1691            ChunkCodec::Lz4,
1692            false,
1693        )
1694        .unwrap();
1695        let size = |i: usize| std::fs::metadata(s.path(i)).unwrap().len();
1696        assert!(size(2) < size(0), "cell file {} vs {}", size(2), size(0));
1697        assert!(size(3) < size(1), "gene file {} vs {}", size(3), size(1));
1698    }
1699
1700    #[test]
1701    fn test_v4_chunk_keeps_unsorted_and_duplicate_indices() {
1702        let chunk = CsrCellChunk::from_data(&[3u32, 1, 2, 5], &[9u32, 2, 2, 400], 5, TARGET, true);
1703        let bytes = chunk.to_v4_bytes(TARGET, ChunkCodec::Lz4).unwrap();
1704        let ctx = V4Ctx {
1705            target_size: TARGET,
1706            cell_lib: None,
1707            cell_norm: None,
1708            is_cell: true,
1709        };
1710        let back = v4_cell_chunk(&bytes, &ctx, &mut V4Decoded::default()).unwrap();
1711        assert_cells_equal(&chunk, &back);
1712    }
1713
1714    #[test]
1715    fn test_default_writer_emits_v4_and_converter_refuses_it() {
1716        let s = Scratch::new("guards", 2);
1717        let mut writer = CellGeneSparseWriter::new(s.path(0), true, 1, 3, TARGET).unwrap();
1718        writer
1719            .write_cell_chunk(CsrCellChunk::from_data(&[1u32], &[0u32], 0, TARGET, true))
1720            .unwrap();
1721        writer.finalise().unwrap();
1722        assert_eq!(peek_file_version(s.path(0)).unwrap(), SC_FILE_VERSION);
1723        assert!(matches!(
1724            convert_cell_file_to_v4(s.path(0), s.path(1), ChunkCodec::Lz4, false),
1725            Err(ScDataErrors::FileVersionMismatch { .. })
1726        ));
1727    }
1728
1729    #[test]
1730    fn test_peek_file_version_distinguishes_v3_and_v4() {
1731        let s = Scratch::new("peek", 2);
1732        let chunk = || CsrCellChunk::from_data(&[1u32, 2], &[0u32, 1], 0, TARGET, true);
1733        let mut v3 = CellGeneSparseWriter::new_v3(s.path(0), true, 1, 3, TARGET).unwrap();
1734        v3.write_cell_chunk(chunk()).unwrap();
1735        v3.finalise().unwrap();
1736        let mut v4 = CellGeneSparseWriter::new(s.path(1), true, 1, 3, TARGET).unwrap();
1737        v4.write_cell_chunk(chunk()).unwrap();
1738        v4.finalise().unwrap();
1739        assert_eq!(peek_file_version(s.path(0)).unwrap(), SC_FILE_VERSION_V3);
1740        assert_eq!(peek_file_version(s.path(1)).unwrap(), SC_FILE_VERSION);
1741    }
1742
1743    #[test]
1744    fn test_default_gene_file_is_v4_and_matches_the_v3_one() {
1745        let s = Scratch::new("genefile", 3);
1746        write_v3_store(s.path(0), s.path(1), TARGET);
1747        crate::gene_file_io::write_gene_file(s.path(0), s.path(2), Some(500), false).unwrap();
1748        assert_eq!(peek_file_version(s.path(2)).unwrap(), SC_FILE_VERSION);
1749
1750        let (v3, v4) = (
1751            ParallelSparseReader::new(s.path(1)).unwrap(),
1752            ParallelSparseReader::new(s.path(2)).unwrap(),
1753        );
1754        let all: Vec<usize> = (0..N_GENES).collect();
1755        for (a, b) in v3
1756            .read_gene_parallel(&all)
1757            .unwrap()
1758            .iter()
1759            .zip(&v4.read_gene_parallel(&all).unwrap())
1760        {
1761            assert_genes_equal(a, b);
1762        }
1763    }
1764
1765    #[test]
1766    fn test_upgrade_store_replaces_files_and_keeps_backups() {
1767        let s = Scratch::new("upgrade", 4);
1768        write_v3_store(s.path(0), s.path(1), TARGET);
1769        let cells_before = ParallelSparseReader::new(s.path(0))
1770            .unwrap()
1771            .get_all_cells()
1772            .unwrap();
1773        upgrade_store_to_v4(s.path(0), Some(s.path(1)), ChunkCodec::Lz4, true, false).unwrap();
1774
1775        assert_eq!(peek_file_version(s.path(0)).unwrap(), SC_FILE_VERSION);
1776        assert_eq!(peek_file_version(s.path(1)).unwrap(), SC_FILE_VERSION);
1777        let backup = format!("{}.v3.bak", s.path(0));
1778        assert_eq!(peek_file_version(&backup).unwrap(), SC_FILE_VERSION_V3);
1779        for (a, b) in cells_before.iter().zip(
1780            &ParallelSparseReader::new(s.path(0))
1781                .unwrap()
1782                .get_all_cells()
1783                .unwrap(),
1784        ) {
1785            assert_cells_equal(a, b);
1786        }
1787        for p in [backup, format!("{}.v3.bak", s.path(1))] {
1788            let _ = std::fs::remove_file(p);
1789        }
1790    }
1791
1792    #[test]
1793    fn test_archive_round_trips_a_v4_cell_file() {
1794        let s = Scratch::new("archive", 6);
1795        write_v3_store(s.path(0), s.path(1), TARGET);
1796        convert_cell_file_to_v4(s.path(0), s.path(2), ChunkCodec::Lz4, false).unwrap();
1797        crate::archive_io::archive_cell_file(s.path(2), s.path(3), 3, false).unwrap();
1798        crate::archive_io::restore_archive(s.path(3), s.path(4), s.path(5), Some(500), false)
1799            .unwrap();
1800
1801        let original = ParallelSparseReader::new(s.path(0)).unwrap();
1802        let restored = ParallelSparseReader::new(s.path(4)).unwrap();
1803        let all: Vec<usize> = (0..N_CELLS).collect();
1804        for (a, b) in original
1805            .read_cells_parallel(&all)
1806            .unwrap()
1807            .iter()
1808            .zip(&restored.read_cells_parallel(&all).unwrap())
1809        {
1810            assert_cells_equal(a, b);
1811        }
1812    }
1813}