use lz4_flex::block::{compress as lz4_compress, decompress_into as lz4_decompress_into};
use rayon::prelude::*;
use std::time::Instant;
use thousands::Separable;
use crate::data_io::*;
use crate::prelude::*;
pub(crate) const V4_HEADER_LEN: usize = 32;
const PACK_BLOCK: usize = 128;
const FLAG_NORM_STORED: u8 = 1;
const FLAG_RAW_MINUS_ONE: u8 = 2;
const NORM_LUT_MAX: usize = 255;
pub(crate) const GENE_NORM_LUT_COUNTS: usize = 4;
const ZSTD_LEVEL: i32 = 1;
const CONVERT_CELL_BATCH: usize = 4_096;
const CONVERT_GENE_BATCH: usize = 64;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChunkCodec {
None,
Lz4,
Zstd,
}
impl ChunkCodec {
fn to_u8(self) -> u8 {
match self {
ChunkCodec::None => 0,
ChunkCodec::Lz4 => 1,
ChunkCodec::Zstd => 2,
}
}
fn from_u8(b: u8) -> Result<Self, ScDataErrors> {
match b {
0 => Ok(ChunkCodec::None),
1 => Ok(ChunkCodec::Lz4),
2 => Ok(ChunkCodec::Zstd),
other => Err(ScDataErrors::V4ChunkCorrupt(format!(
"unknown codec byte {other}"
))),
}
}
}
fn pack(vals: &[u32], out: &mut Vec<u8>) {
for block in vals.chunks(PACK_BLOCK) {
let max = block.iter().copied().max().unwrap_or(0);
let width = (32 - max.leading_zeros()) as usize;
out.push(width as u8);
let (mut acc, mut nbits) = (0u64, 0usize);
for &v in block {
acc |= (v as u64) << nbits;
nbits += width;
while nbits >= 8 {
out.push(acc as u8);
acc >>= 8;
nbits -= 8;
}
}
if nbits > 0 {
out.push(acc as u8);
}
}
}
#[inline(always)]
fn read_word(b: &[u8], at: usize) -> u64 {
match b.get(at..at + 8) {
Some(s) => u64::from_le_bytes(s.try_into().expect("8-byte slice")),
None => {
let mut tmp = [0u8; 8];
let rest = &b[at..];
tmp[..rest.len()].copy_from_slice(rest);
u64::from_le_bytes(tmp)
}
}
}
#[inline(always)]
fn unpack_full<const W: usize>(src: &[u8], out: &mut [u32; PACK_BLOCK]) {
let mask = if W >= 32 { u32::MAX } else { (1u32 << W) - 1 };
for g in 0..PACK_BLOCK / 8 {
for j in 0..8 {
let bit = j * W;
let at = g * W + (bit >> 3);
let word = u64::from_le_bytes(src[at..at + 8].try_into().expect("8-byte slice"));
out[g * 8 + j] = ((word >> (bit & 7)) as u32) & mask;
}
}
}
#[inline(always)]
fn unpack_full_dispatch(width: usize, src: &[u8], out: &mut [u32; PACK_BLOCK]) {
macro_rules! by_width {
($($w:literal),*) => {
match width {
$($w => unpack_full::<$w>(src, out),)*
_ => unreachable!("width checked to be 1..=32"),
}
};
}
by_width!(
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,
26, 27, 28, 29, 30, 31, 32
);
}
fn unpack(buf: &[u8], n: usize, out: &mut Vec<u32>) -> Result<usize, ScDataErrors> {
out.reserve(n);
let mut pos = 0usize;
let mut left = n;
while left > 0 {
let take = left.min(PACK_BLOCK);
let width = *buf.get(pos).ok_or_else(truncated)? as usize;
if width > 32 {
return Err(ScDataErrors::V4ChunkCorrupt(format!(
"bit width {width} above 32"
)));
}
pos += 1;
let need = (take * width).div_ceil(8);
let block = buf.get(pos..pos + need).ok_or_else(truncated)?;
if width == 0 {
out.resize(out.len() + take, 0);
} else if take == PACK_BLOCK && buf.len() >= pos + need + 8 {
let at = out.len();
out.resize(at + PACK_BLOCK, 0);
let dst: &mut [u32; PACK_BLOCK] = (&mut out[at..at + PACK_BLOCK])
.try_into()
.expect("128-value slice");
unpack_full_dispatch(width, &buf[pos..], dst);
} else {
let mask = if width == 32 {
u32::MAX
} else {
(1u32 << width) - 1
};
for k in 0..take {
let bit = k * width;
out.push(((read_word(block, bit >> 3) >> (bit & 7)) as u32) & mask);
}
}
pos += need;
left -= take;
}
Ok(pos)
}
fn packed_len(buf: &[u8], n: usize) -> Result<usize, ScDataErrors> {
let mut pos = 0usize;
let mut left = n;
while left > 0 {
let take = left.min(PACK_BLOCK);
let width = *buf.get(pos).ok_or_else(truncated)? as usize;
if width > 32 {
return Err(ScDataErrors::V4ChunkCorrupt(format!(
"bit width {width} above 32"
)));
}
pos += 1 + (take * width).div_ceil(8);
left -= take;
}
if pos > buf.len() {
return Err(truncated());
}
Ok(pos)
}
fn truncated() -> ScDataErrors {
ScDataErrors::V4ChunkCorrupt("body ends early".to_string())
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct V4Header {
pub nnz: usize,
body_len: usize,
pub aux: u64,
pub original_index: usize,
pub to_keep: bool,
flags: u8,
codec: ChunkCodec,
}
impl V4Header {
pub(crate) fn parse(chunk: &[u8]) -> Result<Self, ScDataErrors> {
if chunk.len() < V4_HEADER_LEN {
return Err(ScDataErrors::ChunkBufferTooSmall {
expected: V4_HEADER_LEN,
found: chunk.len(),
});
}
let u32_at = |at: usize| {
u32::from_le_bytes(chunk[at..at + 4].try_into().expect("4-byte slice")) as usize
};
let u64_at =
|at: usize| u64::from_le_bytes(chunk[at..at + 8].try_into().expect("8-byte slice"));
Ok(Self {
nnz: u32_at(0),
body_len: u32_at(4),
aux: u64_at(8),
original_index: u64_at(16) as usize,
to_keep: chunk[24] != 0,
flags: chunk[25],
codec: ChunkCodec::from_u8(chunk[26])?,
})
}
pub(crate) fn norm_stored(&self) -> bool {
self.flags & FLAG_NORM_STORED != 0
}
}
struct EncodeIn<'a> {
indices: &'a [u32],
raw: &'a [u32],
norm: &'a [F16],
aux: u64,
original_index: usize,
to_keep: bool,
norm_derivable: bool,
}
fn encode_chunk(p: &EncodeIn, codec: ChunkCodec) -> Vec<u8> {
let n = p.indices.len();
let mut deltas = Vec::with_capacity(n);
let mut prev = 0u32;
for (k, &x) in p.indices.iter().enumerate() {
deltas.push(if k == 0 {
x
} else {
x.wrapping_sub(prev).wrapping_sub(1)
});
prev = x;
}
let mut flags = 0u8;
let mut body = Vec::with_capacity(n * 2);
pack(&deltas, &mut body);
if p.raw.iter().all(|&x| x >= 1) {
flags |= FLAG_RAW_MINUS_ONE;
let minus_one: Vec<u32> = p.raw.iter().map(|&x| x - 1).collect();
pack(&minus_one, &mut body);
} else {
pack(p.raw, &mut body);
}
if !p.norm_derivable {
flags |= FLAG_NORM_STORED;
for v in p.norm {
body.extend_from_slice(&v.to_le_bytes());
}
}
let (used, payload) = match codec {
ChunkCodec::None => (ChunkCodec::None, None),
ChunkCodec::Lz4 => (ChunkCodec::Lz4, Some(lz4_compress(&body))),
ChunkCodec::Zstd => (
ChunkCodec::Zstd,
zstd::bulk::compress(&body, ZSTD_LEVEL).ok(),
),
};
let (used, payload) = match payload {
Some(c) if c.len() < body.len() => (used, c),
_ => (ChunkCodec::None, body.clone()),
};
let mut out = Vec::with_capacity(V4_HEADER_LEN + payload.len());
out.extend_from_slice(&(n as u32).to_le_bytes());
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(&p.aux.to_le_bytes());
out.extend_from_slice(&(p.original_index as u64).to_le_bytes());
out.extend_from_slice(&[p.to_keep as u8, flags, used.to_u8(), 0, 0, 0, 0, 0]);
out.extend_from_slice(&payload);
out
}
impl CsrCellChunk {
pub fn to_v4_bytes(
&self,
target_size: f32,
codec: ChunkCodec,
) -> Result<Vec<u8>, ScDataErrors> {
let n = self.indices.len();
if self.data_raw.len() != n || self.data_norm.len() != n {
return Err(ScDataErrors::V4ChunkCorrupt(format!(
"cell {} has layers of unequal length",
self.original_index
)));
}
let raw: Vec<u32> = self.data_raw.iter().collect();
let lib = self.library_size as f32;
let norm_derivable = target_size != 0.0
&& raw
.iter()
.zip(&self.data_norm)
.all(|(&x, v)| norm_value(x as f32, lib, target_size).to_bits() == v.to_bits());
Ok(encode_chunk(
&EncodeIn {
indices: &self.indices,
raw: &raw,
norm: &self.data_norm,
aux: self.library_size as u64,
original_index: self.original_index,
to_keep: self.to_keep,
norm_derivable,
},
codec,
))
}
}
impl CscGeneChunk {
pub fn to_v4_bytes(
&self,
cell_lib: &[f32],
target_size: f32,
codec: ChunkCodec,
) -> Result<Vec<u8>, ScDataErrors> {
let n = self.indices.len();
if self.data_raw.len() != n || self.data_norm.len() != n {
return Err(ScDataErrors::V4ChunkCorrupt(format!(
"gene {} has layers of unequal length",
self.original_index
)));
}
let raw: Vec<u32> = self.data_raw.iter().collect();
let norm_derivable = target_size != 0.0
&& self
.indices
.iter()
.zip(&raw)
.zip(&self.data_norm)
.all(|((&cell, &x), v)| {
cell_lib.get(cell as usize).is_some_and(|&lib| {
norm_value(x as f32, lib, target_size).to_bits() == v.to_bits()
})
});
Ok(encode_chunk(
&EncodeIn {
indices: &self.indices,
raw: &raw,
norm: &self.data_norm,
aux: self.avg_exp.to_bits() as u64,
original_index: self.original_index,
to_keep: self.to_keep,
norm_derivable,
},
codec,
))
}
}
#[derive(Default)]
pub(crate) struct V4Decoded {
pub bytes: Vec<u8>,
pub idx: Vec<u32>,
pub raw: Vec<u32>,
pub norm: Vec<f32>,
}
pub(crate) struct V4Ctx<'a> {
pub target_size: f32,
pub cell_lib: Option<&'a [f32]>,
pub cell_norm: Option<&'a [u16]>,
pub is_cell: bool,
}
pub(crate) fn decode_v4(
chunk: &[u8],
ctx: &V4Ctx,
want_raw: bool,
want_norm: bool,
s: &mut V4Decoded,
) -> Result<V4Header, ScDataErrors> {
let V4Decoded {
bytes,
idx,
raw,
norm,
} = s;
idx.clear();
norm.clear();
decode_v4_into(
chunk,
ctx,
want_raw,
bytes,
raw,
idx,
want_norm.then_some(norm),
)
}
pub(crate) fn decode_v4_into(
chunk: &[u8],
ctx: &V4Ctx,
want_raw: bool,
bytes: &mut Vec<u8>,
raw: &mut Vec<u32>,
idx: &mut Vec<u32>,
norm: Option<&mut Vec<f32>>,
) -> Result<V4Header, ScDataErrors> {
let h = V4Header::parse(chunk)?;
let stored = &chunk[V4_HEADER_LEN..];
let body: &[u8] = match h.codec {
ChunkCodec::None => stored,
ChunkCodec::Lz4 => {
bytes.resize(h.body_len, 0);
let n = lz4_decompress_into(stored, bytes)
.map_err(|e| ScDataErrors::V4ChunkCorrupt(format!("lz4: {e}")))?;
&bytes[..n]
}
ChunkCodec::Zstd => {
bytes.resize(h.body_len, 0);
let n = zstd::bulk::decompress_to_buffer(stored, &mut bytes[..])
.map_err(|e| ScDataErrors::V4ChunkCorrupt(format!("zstd: {e}")))?;
&bytes[..n]
}
};
let n = h.nnz;
let start = idx.len();
let used = unpack(body, n, idx)?;
let mut prev = 0u32;
for (k, v) in idx[start..].iter_mut().enumerate() {
if k > 0 {
*v = prev.wrapping_add(*v).wrapping_add(1);
}
prev = *v;
}
let norm_stored = h.flags & FLAG_NORM_STORED != 0;
let want_norm = norm.is_some();
let need_raw = want_raw || (want_norm && !norm_stored);
let rest = &body[used..];
let raw_len = if need_raw {
raw.clear();
let len = unpack(rest, n, raw)?;
if h.flags & FLAG_RAW_MINUS_ONE != 0 {
raw.iter_mut().for_each(|x| *x = x.wrapping_add(1));
}
len
} else if want_norm {
packed_len(rest, n)?
} else {
0
};
let Some(norm) = norm else {
return Ok(h);
};
norm.reserve(n);
if norm_stored {
let stored_norm = rest.get(raw_len..raw_len + n * 2).ok_or_else(truncated)?;
norm.extend(
stored_norm
.chunks_exact(2)
.map(|c| F16::from_le_bytes([c[0], c[1]]).to_f32()),
);
} else if ctx.is_cell {
let lib = h.aux as f32;
let top = raw.iter().copied().max().unwrap_or(0) as usize;
let lut: Vec<f32> = (0..=top.min(NORM_LUT_MAX))
.map(|x| norm_value(x as f32, lib, ctx.target_size).to_f32())
.collect();
norm.extend(raw.iter().map(|&x| match lut.get(x as usize) {
Some(&v) => v,
None => norm_value(x as f32, lib, ctx.target_size).to_f32(),
}));
} else {
let cell_lib = match ctx.cell_lib {
Some(table) => table,
None if n == 0 => &[],
None => return Err(ScDataErrors::V4MissingCellLibraryTable),
};
let mut outside = None;
let table = ctx.cell_norm.unwrap_or(&[]);
norm.extend(idx[start..].iter().zip(raw.iter()).map(|(&cell, &x)| {
let slot = (x as usize).wrapping_sub(1);
if slot < GENE_NORM_LUT_COUNTS
&& let Some(&bits) = table.get(cell as usize * GENE_NORM_LUT_COUNTS + slot)
{
return F16::from_bits(bits).to_f32();
}
match cell_lib.get(cell as usize) {
Some(&lib) => norm_value(x as f32, lib, ctx.target_size).to_f32(),
None => {
outside = Some(cell);
0.0
}
}
}));
if let Some(cell) = outside {
return Err(ScDataErrors::V4ChunkCorrupt(format!(
"cell {cell} outside the library table"
)));
}
}
Ok(h)
}
pub(crate) fn gene_norm_table(cell_lib: &[f32], target_size: f32) -> Vec<u16> {
let mut table = vec![0u16; cell_lib.len() * GENE_NORM_LUT_COUNTS];
table
.par_chunks_mut(GENE_NORM_LUT_COUNTS)
.zip(cell_lib.par_iter())
.for_each(|(row, &lib)| {
for (k, v) in row.iter_mut().enumerate() {
*v = norm_value((k + 1) as f32, lib, target_size).to_bits();
}
});
table
}
pub(crate) struct V4Run<'a> {
idx: &'a [u32],
raw: &'a [u32],
norm: &'a [f32],
}
impl<'a> V4Run<'a> {
pub(crate) fn new(s: &'a V4Decoded) -> Self {
Self {
idx: &s.idx,
raw: &s.raw,
norm: &s.norm,
}
}
}
impl RunSource for V4Run<'_> {
#[inline]
fn nnz(&self) -> usize {
self.idx.len()
}
#[inline(always)]
fn index_at(&self, i: usize) -> u32 {
self.idx[i]
}
#[inline(always)]
fn raw_at(&self, i: usize) -> u32 {
self.raw[i]
}
#[inline(always)]
fn norm_at(&self, i: usize) -> f32 {
self.norm[i]
}
fn extend_indices(&self, out: &mut Vec<u32>) {
out.extend_from_slice(self.idx);
}
fn extend_raw<T: num_traits::FromPrimitive>(
&self,
out: &mut Vec<T>,
) -> Result<(), ScDataErrors> {
extend_raw_checked(out, self.raw[..self.idx.len()].iter().copied())
}
fn extend_norm(&self, out: &mut Vec<f32>) {
out.extend_from_slice(&self.norm[..self.idx.len()]);
}
}
pub(crate) fn v4_cell_chunk(
chunk: &[u8],
ctx: &V4Ctx,
s: &mut V4Decoded,
) -> Result<CsrCellChunk, ScDataErrors> {
let h = decode_v4(chunk, ctx, true, true, s)?;
Ok(CsrCellChunk {
data_raw: RawCounts::from_u32_auto(&s.raw),
data_norm: s.norm.iter().map(|&x| F16::from_f32(x)).collect(),
library_size: h.aux as usize,
indices: s.idx.clone(),
original_index: h.original_index,
to_keep: h.to_keep,
})
}
pub(crate) fn v4_gene_chunk(
chunk: &[u8],
ctx: &V4Ctx,
s: &mut V4Decoded,
) -> Result<CscGeneChunk, ScDataErrors> {
let h = decode_v4(chunk, ctx, true, true, s)?;
Ok(CscGeneChunk {
data_raw: RawCounts::from_u32_auto(&s.raw),
data_norm: s.norm.iter().map(|&x| F16::from_f32(x)).collect(),
avg_exp: F16::from_bits(h.aux as u16),
nnz: h.nnz,
indices: s.idx.clone(),
original_index: h.original_index,
to_keep: h.to_keep,
})
}
#[derive(Clone, Copy, Debug)]
pub struct ChunkFormat {
pub version: u32,
pub codec: ChunkCodec,
pub target_size: f32,
}
impl ChunkFormat {
pub fn is_v4(&self) -> bool {
self.version == SC_FILE_VERSION
}
pub fn encode_cell(&self, chunk: &CsrCellChunk) -> Result<Vec<u8>, ScDataErrors> {
if self.is_v4() {
chunk.to_v4_bytes(self.target_size, self.codec)
} else {
Ok(chunk.to_compressed_bytes()?)
}
}
pub fn encode_gene(
&self,
chunk: &CscGeneChunk,
cell_lib: &[f32],
) -> Result<Vec<u8>, ScDataErrors> {
if self.is_v4() {
chunk.to_v4_bytes(cell_lib, self.target_size, self.codec)
} else {
Ok(chunk.to_compressed_bytes()?)
}
}
pub(crate) fn encode_gene_derivable(
&self,
indices: &[u32],
raw: &[u32],
avg_exp: F16,
original_index: usize,
) -> Vec<u8> {
debug_assert!(self.is_v4() && indices.len() == raw.len());
encode_chunk(
&EncodeIn {
indices,
raw,
norm: &[],
aux: avg_exp.to_bits() as u64,
original_index,
to_keep: true,
norm_derivable: true,
},
self.codec,
)
}
}
fn chunk_order(header: &SparseDataHeader) -> Result<Vec<usize>, ScDataErrors> {
let mut order = vec![usize::MAX; header.no_chunks];
for (&orig, &chunk) in &header.index_map {
*order.get_mut(chunk).ok_or_else(|| {
ScDataErrors::V4ChunkCorrupt(format!("chunk {chunk} outside the file"))
})? = orig;
}
Ok(order)
}
fn require_v3(reader: &ParallelSparseReader) -> Result<(), ScDataErrors> {
if reader.file_version() != SC_FILE_VERSION_V3 {
return Err(ScDataErrors::FileVersionMismatch {
expected: SC_FILE_VERSION_V3,
found: reader.file_version(),
});
}
Ok(())
}
pub fn convert_cell_file_to_v4(
cell_in: &str,
cell_out: &str,
codec: ChunkCodec,
verbose: bool,
) -> Result<Vec<u64>, ScDataErrors> {
let start = Instant::now();
let reader = ParallelSparseReader::new(cell_in)?;
require_v3(&reader)?;
if !reader.is_cell_based() {
return Err(ScDataErrors::ReaderModeMismatch {
actual: "gene-based",
requested: "cell-based",
});
}
let header = reader.get_header();
let target_size = reader.target_size().unwrap_or(0.0);
let order = chunk_order(&header)?;
let mut writer = CellGeneSparseWriter::new(
cell_out,
true,
header.total_cells,
header.total_genes,
target_size,
)?;
let table_len = header
.total_cells
.max(order.iter().copied().max().map_or(0, |m| m + 1));
let mut lib_table = vec![0u64; table_len];
let mut stored_norm = 0usize;
for batch in order.chunks(CONVERT_CELL_BATCH) {
let cells = reader.read_cells_parallel(batch)?;
let payloads = cells
.par_iter()
.map(|cell| {
Ok(CellPayload {
original_index: cell.original_index,
library_size: cell.library_size,
bytes: cell.to_v4_bytes(target_size, codec)?,
})
})
.collect::<Result<Vec<_>, ScDataErrors>>()?;
for p in &payloads {
lib_table[p.original_index] = p.library_size as u64;
stored_norm += (V4Header::parse(&p.bytes)?.flags & FLAG_NORM_STORED != 0) as usize;
}
writer.write_compressed_cell_chunks(&payloads)?;
}
writer.finalise()?;
if verbose {
println!(
" Converted {} cells to v4 ({} with stored norm) in {:.2?}.",
header.no_chunks.separate_with_underscores(),
stored_norm.separate_with_underscores(),
start.elapsed()
);
}
Ok(lib_table)
}
pub fn convert_gene_file_to_v4(
gene_in: &str,
gene_out: &str,
cell_lib: &[u64],
codec: ChunkCodec,
verbose: bool,
) -> Result<(), ScDataErrors> {
let start = Instant::now();
let reader = ParallelSparseReader::new(gene_in)?;
require_v3(&reader)?;
if !reader.is_gene_based() {
return Err(ScDataErrors::ReaderModeMismatch {
actual: "cell-based",
requested: "gene-based",
});
}
let header = reader.get_header();
let target_size = reader.target_size().unwrap_or(0.0);
let order = chunk_order(&header)?;
let lib_f32: Vec<f32> = cell_lib.iter().map(|&l| l as f32).collect();
let mut writer = CellGeneSparseWriter::new(
gene_out,
false,
header.total_cells,
header.total_genes,
target_size,
)?;
let mut stored_norm = 0usize;
for batch in order.chunks(CONVERT_GENE_BATCH) {
let genes = reader.read_gene_parallel(batch)?;
let payloads = genes
.par_iter()
.map(|gene| {
Ok((
gene.original_index,
gene.to_v4_bytes(&lib_f32, target_size, codec)?,
))
})
.collect::<Result<Vec<_>, ScDataErrors>>()?;
for (_, bytes) in &payloads {
stored_norm += (V4Header::parse(bytes)?.flags & FLAG_NORM_STORED != 0) as usize;
}
writer.write_compressed_gene_chunks(&payloads)?;
}
writer.set_cell_library_table(cell_lib.to_vec())?;
writer.finalise()?;
if verbose {
println!(
" Converted {} genes to v4 ({} with stored norm) in {:.2?}.",
header.no_chunks.separate_with_underscores(),
stored_norm.separate_with_underscores(),
start.elapsed()
);
}
Ok(())
}
pub fn convert_store_v3_to_v4(
cell_in: &str,
gene_in: Option<&str>,
cell_out: &str,
gene_out: Option<&str>,
codec: ChunkCodec,
verbose: bool,
) -> Result<(), ScDataErrors> {
let lib = convert_cell_file_to_v4(cell_in, cell_out, codec, verbose)?;
if let (Some(gene_in), Some(gene_out)) = (gene_in, gene_out) {
convert_gene_file_to_v4(gene_in, gene_out, &lib, codec, verbose)?;
}
Ok(())
}
pub fn upgrade_store_to_v4(
cell_path: &str,
gene_path: Option<&str>,
codec: ChunkCodec,
keep_backup: bool,
verbose: bool,
) -> Result<(), ScDataErrors> {
let tmp = |p: &str| format!("{p}.v4.tmp");
let cleanup = |paths: &[Option<&str>]| {
for p in paths.iter().flatten() {
let _ = std::fs::remove_file(tmp(p));
}
};
let result = (|| {
let cell_tmp = tmp(cell_path);
let lib = convert_cell_file_to_v4(cell_path, &cell_tmp, codec, verbose)?;
if let Some(gene_path) = gene_path {
convert_gene_file_to_v4(gene_path, &tmp(gene_path), &lib, codec, verbose)?;
}
for p in std::iter::once(cell_path).chain(gene_path) {
let (old, new) = (
ParallelSparseReader::new(p)?.get_header(),
ParallelSparseReader::new(&tmp(p))?.get_header(),
);
if (old.total_cells, old.total_genes, old.no_chunks)
!= (new.total_cells, new.total_genes, new.no_chunks)
{
return Err(ScDataErrors::V4ChunkCorrupt(format!(
"converted {p} does not match the original"
)));
}
}
Ok(())
})();
if let Err(e) = result {
cleanup(&[Some(cell_path), gene_path]);
return Err(e);
}
for p in std::iter::once(cell_path).chain(gene_path) {
if keep_backup {
std::fs::rename(p, format!("{p}.v3.bak"))?;
}
std::fs::rename(tmp(p), p)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use indexmap::IndexSet;
#[test]
fn test_pack_unpack_round_trip_widths() {
for &(n, max) in &[
(0usize, 0u32),
(1, 5),
(127, 1),
(128, 255),
(129, 70_000),
(300, 0),
(500, u32::MAX),
] {
let vals: Vec<u32> = (0..n)
.map(|i| ((i as u64 * 2_654_435_761) % (max as u64 + 1)) as u32)
.collect();
let mut buf = Vec::new();
pack(&vals, &mut buf);
let mut out = Vec::new();
let used = unpack(&buf, n, &mut out).unwrap();
assert_eq!(out, vals, "n {n} max {max}");
assert_eq!(used, buf.len());
assert_eq!(packed_len(&buf, n).unwrap(), buf.len());
}
}
#[test]
fn test_unpack_every_width_through_the_specialised_path() {
for width in 1..=32u32 {
let max = if width == 32 {
u32::MAX
} else {
(1u32 << width) - 1
};
let vals: Vec<u32> = (0..PACK_BLOCK * 3 + 5)
.map(|i| {
if i % 128 == 0 {
max
} else {
(i as u32).wrapping_mul(2_654_435_761) & max
}
})
.collect();
let mut buf = Vec::new();
pack(&vals, &mut buf);
let mut out = Vec::new();
assert_eq!(unpack(&buf, vals.len(), &mut out).unwrap(), buf.len());
assert_eq!(out, vals, "width {width}");
}
}
#[test]
fn test_unpack_rejects_truncated_and_wide() {
let mut buf = Vec::new();
pack(&[1, 2, 3, 400], &mut buf);
let mut out = Vec::new();
assert!(unpack(&buf[..buf.len() - 1], 4, &mut out).is_err());
assert!(unpack(&[33], 1, &mut out).is_err());
}
struct Scratch(Vec<std::path::PathBuf>);
impl Scratch {
fn new(tag: &str, n: usize) -> Self {
let dir = std::env::temp_dir();
Self(
(0..n)
.map(|i| dir.join(format!("bx_v4_{tag}_{}_{i}.bin", std::process::id())))
.collect(),
)
}
fn path(&self, i: usize) -> &str {
self.0[i].to_str().unwrap()
}
}
impl Drop for Scratch {
fn drop(&mut self) {
for p in &self.0 {
let _ = std::fs::remove_file(p);
}
}
}
const N_CELLS: usize = 300;
const N_GENES: usize = 40;
const TARGET: f32 = 1e4;
fn synthetic_cells() -> Vec<CsrCellChunk> {
let mut state = 12345u64;
let mut next = move || {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(state >> 33) as u32
};
(0..N_CELLS)
.map(|c| {
let mut genes = Vec::new();
let mut counts = Vec::new();
if c != 7 {
for g in 0..N_GENES as u32 {
if next() % 3 == 0 {
genes.push(g);
counts.push(1 + next() % 4 + if next() % 50 == 0 { 300 } else { 0 });
}
}
}
if c == 11 {
match genes.last() {
Some(&g) if g == N_GENES as u32 - 1 => *counts.last_mut().unwrap() = 70_000,
_ => {
genes.push(N_GENES as u32 - 1);
counts.push(70_000);
}
}
}
let mut chunk = CsrCellChunk::from_data(&counts, &genes, c, TARGET, true);
if c == 13 && !chunk.data_norm.is_empty() {
chunk.data_norm[0] = F16::from_f32(chunk.data_norm[0].to_f32() + 0.5);
}
chunk
})
.collect()
}
fn write_v3_store(cell_path: &str, gene_path: &str, target: f32) {
let mut writer =
CellGeneSparseWriter::new_v3(cell_path, true, N_CELLS, N_GENES, target).unwrap();
for chunk in synthetic_cells() {
writer.write_cell_chunk(chunk).unwrap();
}
writer.finalise().unwrap();
crate::gene_file_io::write_gene_file_v3(cell_path, gene_path, Some(500), false).unwrap();
}
fn assert_cells_equal(a: &CsrCellChunk, b: &CsrCellChunk) {
assert_eq!(a.original_index, b.original_index);
assert_eq!(a.library_size, b.library_size);
assert_eq!(a.to_keep, b.to_keep);
assert_eq!(a.indices, b.indices);
assert_eq!(
a.data_raw.iter().collect::<Vec<_>>(),
b.data_raw.iter().collect::<Vec<_>>()
);
let bits = |c: &CsrCellChunk| c.data_norm.iter().map(|v| v.to_bits()).collect::<Vec<_>>();
assert_eq!(bits(a), bits(b));
}
fn assert_genes_equal(a: &CscGeneChunk, b: &CscGeneChunk) {
assert_eq!(a.original_index, b.original_index);
assert_eq!(a.nnz, b.nnz);
assert_eq!(a.avg_exp.to_bits(), b.avg_exp.to_bits());
assert_eq!(a.indices, b.indices);
assert_eq!(
a.data_raw.iter().collect::<Vec<_>>(),
b.data_raw.iter().collect::<Vec<_>>()
);
let bits = |c: &CscGeneChunk| c.data_norm.iter().map(|v| v.to_bits()).collect::<Vec<_>>();
assert_eq!(bits(a), bits(b));
}
fn assert_sparse_equal(
a: &CompressedSparseData2<f32, f32>,
b: &CompressedSparseData2<f32, f32>,
) {
assert_eq!(a.shape, b.shape);
assert_eq!(a.indptr, b.indptr);
assert_eq!(a.indices, b.indices);
assert_eq!(a.data, b.data);
assert_eq!(a.data_2, b.data_2);
}
fn check_conversion(codec: ChunkCodec, target: f32) {
let tag = format!("{codec:?}_{}", target as u32);
let s = Scratch::new(&tag, 4);
write_v3_store(s.path(0), s.path(1), target);
convert_store_v3_to_v4(
s.path(0),
Some(s.path(1)),
s.path(2),
Some(s.path(3)),
codec,
false,
)
.unwrap();
let (cells3, cells4) = (
ParallelSparseReader::new(s.path(0)).unwrap(),
ParallelSparseReader::new(s.path(2)).unwrap(),
);
let (genes3, genes4) = (
ParallelSparseReader::new(s.path(1)).unwrap(),
ParallelSparseReader::new(s.path(3)).unwrap(),
);
assert_eq!(cells3.file_version(), SC_FILE_VERSION_V3);
assert_eq!(cells4.file_version(), SC_FILE_VERSION);
assert_eq!(genes4.file_version(), SC_FILE_VERSION);
assert_eq!(cells4.target_size(), cells3.target_size());
let all_cells: Vec<usize> = (0..N_CELLS).collect();
let all_genes: Vec<usize> = (0..N_GENES).collect();
for (a, b) in cells3
.read_cells_parallel(&all_cells)
.unwrap()
.iter()
.zip(&cells4.read_cells_parallel(&all_cells).unwrap())
{
assert_cells_equal(a, b);
}
for (a, b) in genes3
.read_gene_parallel(&all_genes)
.unwrap()
.iter()
.zip(&genes4.read_gene_parallel(&all_genes).unwrap())
{
assert_genes_equal(a, b);
}
let shuffled: Vec<usize> = (0..N_CELLS).rev().step_by(3).collect();
let keep: IndexSet<u32> = (0..N_CELLS as u32).filter(|c| c % 4 != 1).collect();
for layer in [
DataLayerReturn::Raw,
DataLayerReturn::Norm,
DataLayerReturn::BothLayers,
] {
assert_sparse_equal(
&cells3.read_cells_sparse::<f32>(&shuffled, &layer).unwrap(),
&cells4.read_cells_sparse::<f32>(&shuffled, &layer).unwrap(),
);
for subset in [None, Some(&keep)] {
assert_sparse_equal(
&genes3
.read_genes_sparse::<f32>(&all_genes, subset, &layer)
.unwrap(),
&genes4
.read_genes_sparse::<f32>(&all_genes, subset, &layer)
.unwrap(),
);
}
}
for (a, b) in genes3
.read_gene_parallel_filtered(&all_genes, &keep)
.unwrap()
.iter()
.zip(
&genes4
.read_gene_parallel_filtered(&all_genes, &keep)
.unwrap(),
)
{
assert_genes_equal(a, b);
}
assert_eq!(
cells3.read_cell_library_sizes(&all_cells).unwrap(),
cells4.read_cell_library_sizes(&all_cells).unwrap()
);
assert_eq!(
genes3.read_gene_nnz(&all_genes).unwrap(),
genes4.read_gene_nnz(&all_genes).unwrap()
);
assert_eq!(
cells3.get_header().chunk_offsets.len(),
cells4.get_header().chunk_offsets.len()
);
}
#[test]
fn test_v3_to_v4_round_trip_none() {
check_conversion(ChunkCodec::None, TARGET);
}
#[test]
fn test_v3_to_v4_round_trip_lz4() {
check_conversion(ChunkCodec::Lz4, TARGET);
}
#[test]
fn test_v3_to_v4_round_trip_zstd() {
check_conversion(ChunkCodec::Zstd, TARGET);
}
#[test]
fn test_v3_to_v4_round_trip_unknown_target_stores_norm() {
check_conversion(ChunkCodec::Lz4, 0.0);
}
#[test]
fn test_v4_drops_norm_and_shrinks() {
let s = Scratch::new("shrink", 4);
write_v3_store(s.path(0), s.path(1), TARGET);
convert_store_v3_to_v4(
s.path(0),
Some(s.path(1)),
s.path(2),
Some(s.path(3)),
ChunkCodec::Lz4,
false,
)
.unwrap();
let size = |i: usize| std::fs::metadata(s.path(i)).unwrap().len();
assert!(size(2) < size(0), "cell file {} vs {}", size(2), size(0));
assert!(size(3) < size(1), "gene file {} vs {}", size(3), size(1));
}
#[test]
fn test_v4_chunk_keeps_unsorted_and_duplicate_indices() {
let chunk = CsrCellChunk::from_data(&[3u32, 1, 2, 5], &[9u32, 2, 2, 400], 5, TARGET, true);
let bytes = chunk.to_v4_bytes(TARGET, ChunkCodec::Lz4).unwrap();
let ctx = V4Ctx {
target_size: TARGET,
cell_lib: None,
cell_norm: None,
is_cell: true,
};
let back = v4_cell_chunk(&bytes, &ctx, &mut V4Decoded::default()).unwrap();
assert_cells_equal(&chunk, &back);
}
#[test]
fn test_default_writer_emits_v4_and_converter_refuses_it() {
let s = Scratch::new("guards", 2);
let mut writer = CellGeneSparseWriter::new(s.path(0), true, 1, 3, TARGET).unwrap();
writer
.write_cell_chunk(CsrCellChunk::from_data(&[1u32], &[0u32], 0, TARGET, true))
.unwrap();
writer.finalise().unwrap();
assert_eq!(peek_file_version(s.path(0)).unwrap(), SC_FILE_VERSION);
assert!(matches!(
convert_cell_file_to_v4(s.path(0), s.path(1), ChunkCodec::Lz4, false),
Err(ScDataErrors::FileVersionMismatch { .. })
));
}
#[test]
fn test_peek_file_version_distinguishes_v3_and_v4() {
let s = Scratch::new("peek", 2);
let chunk = || CsrCellChunk::from_data(&[1u32, 2], &[0u32, 1], 0, TARGET, true);
let mut v3 = CellGeneSparseWriter::new_v3(s.path(0), true, 1, 3, TARGET).unwrap();
v3.write_cell_chunk(chunk()).unwrap();
v3.finalise().unwrap();
let mut v4 = CellGeneSparseWriter::new(s.path(1), true, 1, 3, TARGET).unwrap();
v4.write_cell_chunk(chunk()).unwrap();
v4.finalise().unwrap();
assert_eq!(peek_file_version(s.path(0)).unwrap(), SC_FILE_VERSION_V3);
assert_eq!(peek_file_version(s.path(1)).unwrap(), SC_FILE_VERSION);
}
#[test]
fn test_default_gene_file_is_v4_and_matches_the_v3_one() {
let s = Scratch::new("genefile", 3);
write_v3_store(s.path(0), s.path(1), TARGET);
crate::gene_file_io::write_gene_file(s.path(0), s.path(2), Some(500), false).unwrap();
assert_eq!(peek_file_version(s.path(2)).unwrap(), SC_FILE_VERSION);
let (v3, v4) = (
ParallelSparseReader::new(s.path(1)).unwrap(),
ParallelSparseReader::new(s.path(2)).unwrap(),
);
let all: Vec<usize> = (0..N_GENES).collect();
for (a, b) in v3
.read_gene_parallel(&all)
.unwrap()
.iter()
.zip(&v4.read_gene_parallel(&all).unwrap())
{
assert_genes_equal(a, b);
}
}
#[test]
fn test_upgrade_store_replaces_files_and_keeps_backups() {
let s = Scratch::new("upgrade", 4);
write_v3_store(s.path(0), s.path(1), TARGET);
let cells_before = ParallelSparseReader::new(s.path(0))
.unwrap()
.get_all_cells()
.unwrap();
upgrade_store_to_v4(s.path(0), Some(s.path(1)), ChunkCodec::Lz4, true, false).unwrap();
assert_eq!(peek_file_version(s.path(0)).unwrap(), SC_FILE_VERSION);
assert_eq!(peek_file_version(s.path(1)).unwrap(), SC_FILE_VERSION);
let backup = format!("{}.v3.bak", s.path(0));
assert_eq!(peek_file_version(&backup).unwrap(), SC_FILE_VERSION_V3);
for (a, b) in cells_before.iter().zip(
&ParallelSparseReader::new(s.path(0))
.unwrap()
.get_all_cells()
.unwrap(),
) {
assert_cells_equal(a, b);
}
for p in [backup, format!("{}.v3.bak", s.path(1))] {
let _ = std::fs::remove_file(p);
}
}
#[test]
fn test_archive_round_trips_a_v4_cell_file() {
let s = Scratch::new("archive", 6);
write_v3_store(s.path(0), s.path(1), TARGET);
convert_cell_file_to_v4(s.path(0), s.path(2), ChunkCodec::Lz4, false).unwrap();
crate::archive_io::archive_cell_file(s.path(2), s.path(3), 3, false).unwrap();
crate::archive_io::restore_archive(s.path(3), s.path(4), s.path(5), Some(500), false)
.unwrap();
let original = ParallelSparseReader::new(s.path(0)).unwrap();
let restored = ParallelSparseReader::new(s.path(4)).unwrap();
let all: Vec<usize> = (0..N_CELLS).collect();
for (a, b) in original
.read_cells_parallel(&all)
.unwrap()
.iter()
.zip(&restored.read_cells_parallel(&all).unwrap())
{
assert_cells_equal(a, b);
}
}
}