use bincode::{Decode, Encode, config, decode_from_slice, serde::encode_to_vec};
use half::f16;
use indexmap::IndexSet;
use lz4_flex::{compress_prepend_size, decompress_size_prepended};
use memmap2::MmapOptions;
use num_traits::FromPrimitive;
use rayon::prelude::*;
use rustc_hash::{FxHashMap, FxHashSet};
use serde::{Deserialize, Serialize};
use std::{
fs::File,
io::{BufWriter, Seek, SeekFrom, Write},
marker::Sync,
path::Path,
sync::Arc,
};
use crate::prelude::*;
#[derive(Encode, Decode, Serialize, Deserialize, Debug, Clone)]
pub enum RawCounts {
U16(Vec<u16>),
U32(Vec<u32>),
}
const RAW_ELEM_U16: u8 = 2;
const RAW_ELEM_U32: u8 = 4;
const CSR_CHUNK_HEADER_LEN: usize = 32;
const CSC_CHUNK_HEADER_LEN: usize = 36;
const GENE_FLUSH_FREQUENCY: usize = 1_000;
const CELL_FLUSH_FREQUENCY: usize = 100_000;
impl RawCounts {
pub fn get(&self, i: usize) -> u32 {
match self {
RawCounts::U16(v) => v[i] as u32,
RawCounts::U32(v) => v[i],
}
}
pub fn len(&self) -> usize {
match self {
RawCounts::U16(v) => v.len(),
RawCounts::U32(v) => v.len(),
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn iter(&self) -> RawCountsIter<'_> {
RawCountsIter {
inner: self,
pos: 0,
}
}
pub fn elem_size(&self) -> u8 {
match self {
RawCounts::U16(_) => RAW_ELEM_U16,
RawCounts::U32(_) => RAW_ELEM_U32,
}
}
pub fn write_bytes(&self, writer: &mut impl Write) -> std::io::Result<()> {
match self {
RawCounts::U16(v) => {
let mut bytes = Vec::with_capacity(v.len() * 2);
for x in v {
bytes.extend_from_slice(&x.to_le_bytes());
}
writer.write_all(&bytes)
}
RawCounts::U32(v) => {
let mut bytes = Vec::with_capacity(v.len() * 4);
for x in v {
bytes.extend_from_slice(&x.to_le_bytes());
}
writer.write_all(&bytes)
}
}
}
pub fn read_from_buffer(
buffer: &[u8],
count: usize,
elem_size: u8,
) -> Result<Self, BixverseErrors> {
let width = match elem_size {
RAW_ELEM_U32 => 4_usize,
RAW_ELEM_U16 | 0 => 2_usize,
other => return Err(BixverseErrors::RawElemSizeInvalid(other)),
};
let needed = count * width;
if buffer.len() < needed {
return Err(BixverseErrors::ChunkPayloadTruncated {
expected: needed,
found: buffer.len(),
});
}
Ok(if width == 4 {
RawCounts::U32(
buffer[..needed]
.chunks_exact(4)
.map(|c| {
u32::from_le_bytes(c.try_into().expect("4-byte chunk by construction"))
})
.collect(),
)
} else {
RawCounts::U16(
buffer[..needed]
.chunks_exact(2)
.map(|c| {
u16::from_le_bytes(c.try_into().expect("2-byte chunk by construction"))
})
.collect(),
)
})
}
pub fn from_u32_auto(data: &[u32]) -> Self {
if data.iter().all(|&v| v <= u16::MAX as u32) {
RawCounts::U16(data.iter().map(|&v| v as u16).collect())
} else {
RawCounts::U32(data.to_vec())
}
}
}
pub struct RawCountsIter<'a> {
inner: &'a RawCounts,
pos: usize,
}
impl<'a> Iterator for RawCountsIter<'a> {
type Item = u32;
fn next(&mut self) -> Option<u32> {
if self.pos < self.inner.len() {
let val = self.inner.get(self.pos);
self.pos += 1;
Some(val)
} else {
None
}
}
}
#[derive(Clone, Debug)]
pub struct CellQuality {
pub cell_indices: Vec<usize>,
pub gene_indices: Vec<usize>,
pub lib_size: Vec<usize>,
pub nnz: Vec<usize>,
}
impl CellQuality {
pub fn set_cell_indices(&mut self, cell_indices: &[usize]) {
self.cell_indices = cell_indices.to_vec();
}
pub fn set_gene_indices(&mut self, gene_indices: &[usize]) {
self.gene_indices = gene_indices.to_vec();
}
}
#[derive(Clone, Debug)]
pub struct MinCellQuality {
pub min_unique_genes: usize,
pub min_lib_size: usize,
pub min_cells: usize,
pub target_size: f32,
}
#[derive(Debug, Clone)]
pub struct CellOnFileQuality {
pub cells_to_keep: Vec<usize>,
pub genes_to_keep: Vec<usize>,
pub cells_to_keep_set: FxHashSet<usize>,
pub genes_to_keep_set: FxHashSet<usize>,
pub cell_old_to_new: FxHashMap<usize, usize>,
pub gene_old_to_new: FxHashMap<usize, usize>,
}
impl CellOnFileQuality {
pub fn new(cells_to_keep: Vec<usize>, genes_to_keep: Vec<usize>) -> Self {
Self {
cells_to_keep,
genes_to_keep,
cells_to_keep_set: FxHashSet::default(),
genes_to_keep_set: FxHashSet::default(),
cell_old_to_new: FxHashMap::default(),
gene_old_to_new: FxHashMap::default(),
}
}
pub fn generate_maps_sets(&mut self) {
let cells_to_keep_set: FxHashSet<usize> = self.cells_to_keep.iter().cloned().collect();
let genes_to_keep_set: FxHashSet<usize> = self.genes_to_keep.iter().cloned().collect();
let cell_old_to_new: FxHashMap<usize, usize> = self
.cells_to_keep
.iter()
.enumerate()
.map(|(new_idx, &old_idx)| (old_idx, new_idx))
.collect();
let gene_old_to_new: FxHashMap<usize, usize> = self
.genes_to_keep
.iter()
.enumerate()
.map(|(new_idx, &old_idx)| (old_idx, new_idx))
.collect();
self.cells_to_keep_set = cells_to_keep_set;
self.genes_to_keep_set = genes_to_keep_set;
self.cell_old_to_new = cell_old_to_new;
self.gene_old_to_new = gene_old_to_new
}
}
#[derive(Debug)]
pub struct CsrCellChunk {
pub data_raw: RawCounts,
pub data_norm: Vec<F16>,
pub library_size: usize,
pub indices: Vec<u32>,
pub original_index: usize,
pub to_keep: bool,
}
impl CsrCellChunk {
pub fn from_data<T, U>(
data: &[T],
col_idx: &[U],
original_index: usize,
size_factor: f32,
to_keep: bool,
) -> Self
where
T: FloatAndUInt,
U: FloatAndUInt,
{
let data_f32 = data.iter().map(|&x| x.to_f32()).collect::<Vec<f32>>();
let sum = data_f32.iter().sum::<f32>();
let data_norm: Vec<F16> = data_f32
.into_iter()
.map(|x| {
let norm = (x / sum * size_factor).ln_1p();
F16::from(f16::from_f32(norm))
})
.collect();
let raw_u32: Vec<u32> = data.iter().map(|&x| x.to_u32()).collect();
let data_raw = RawCounts::from_u32_auto(&raw_u32);
Self {
data_raw,
data_norm,
library_size: sum as usize,
indices: col_idx.iter().map(|&x| x.to_u32()).collect::<Vec<u32>>(),
original_index,
to_keep,
}
}
pub fn write_to_bytes(&self, writer: &mut impl Write) -> std::io::Result<()> {
writer.write_all(&(self.data_raw.len() as u32).to_le_bytes())?;
writer.write_all(&(self.data_norm.len() as u32).to_le_bytes())?;
writer.write_all(&(self.indices.len() as u32).to_le_bytes())?;
writer.write_all(&(self.library_size as u64).to_le_bytes())?;
writer.write_all(&(self.original_index as u64).to_le_bytes())?;
writer.write_all(&[self.to_keep as u8, self.data_raw.elem_size(), 0, 0])?;
self.data_raw.write_bytes(writer)?;
let mut data_norm_bytes = Vec::with_capacity(self.data_norm.len() * 2);
for v in &self.data_norm {
data_norm_bytes.extend_from_slice(&v.to_bits().to_le_bytes());
}
writer.write_all(&data_norm_bytes)?;
let mut col_indices_bytes = Vec::with_capacity(self.indices.len() * 4);
for i in &self.indices {
col_indices_bytes.extend_from_slice(&i.to_le_bytes());
}
writer.write_all(&col_indices_bytes)?;
Ok(())
}
pub fn read_from_buffer(buffer: &[u8]) -> Result<Self, BixverseErrors> {
if buffer.len() < CSR_CHUNK_HEADER_LEN {
return Err(BixverseErrors::ChunkBufferTooSmall {
expected: CSR_CHUNK_HEADER_LEN,
found: buffer.len(),
});
}
let header = &buffer[0..CSR_CHUNK_HEADER_LEN];
let data_raw_len =
u32::from_le_bytes([header[0], header[1], header[2], header[3]]) as usize;
let data_norm_len =
u32::from_le_bytes([header[4], header[5], header[6], header[7]]) as usize;
let col_indices_len =
u32::from_le_bytes([header[8], header[9], header[10], header[11]]) as usize;
let library_size = u64::from_le_bytes([
header[12], header[13], header[14], header[15], header[16], header[17], header[18],
header[19],
]) as usize;
let original_index = u64::from_le_bytes([
header[20], header[21], header[22], header[23], header[24], header[25], header[26],
header[27],
]) as usize;
let to_keep = header[28] != 0;
let raw_elem_size = header[29];
let data_start = CSR_CHUNK_HEADER_LEN;
let elem_size = if raw_elem_size == RAW_ELEM_U32 {
4usize
} else {
2usize
};
let data_end = data_start + data_raw_len * elem_size;
let norm_end = data_end + data_norm_len * 2;
let total_end = norm_end + col_indices_len * 4;
if buffer.len() < total_end {
return Err(BixverseErrors::ChunkPayloadTruncated {
expected: total_end,
found: buffer.len(),
});
}
let data_raw = RawCounts::read_from_buffer(
&buffer[data_start..data_end],
data_raw_len,
raw_elem_size,
)?;
let data_norm: Vec<F16> = buffer[data_end..norm_end]
.chunks_exact(2)
.map(|c| F16::from_le_bytes(c.try_into().expect("2-byte chunk by construction")))
.collect();
let indices: Vec<u32> = buffer[norm_end..total_end]
.chunks_exact(4)
.map(|c| u32::from_le_bytes(c.try_into().expect("4-byte chunk by construction")))
.collect();
Ok(Self {
data_raw,
data_norm,
library_size,
indices,
original_index,
to_keep,
})
}
pub fn generate_chunks_sparse_data<T, U>(
sparse_data: CompressedSparseData2<T, U>,
cell_qc: MinCellQuality,
) -> (Vec<CsrCellChunk>, CellQuality)
where
T: Clone + Default + Into<u32> + Sync,
U: Clone + Default,
{
let n_cells = sparse_data.indptr.len() - 1;
let n_genes = sparse_data.shape.1;
let mut no_cells_exp_gene = vec![0usize; n_genes];
for i in 0..n_cells {
let start_i = sparse_data.indptr[i] as usize;
let end_i = sparse_data.indptr[i + 1] as usize;
for &gene_idx in &sparse_data.indices[start_i..end_i] {
no_cells_exp_gene[gene_idx as usize] += 1;
}
}
let genes_to_keep: Vec<usize> = (0..n_genes)
.filter(|&i| no_cells_exp_gene[i] >= cell_qc.min_cells)
.collect();
let mut gene_index_map = vec![None; n_genes];
for (new_idx, &old_idx) in genes_to_keep.iter().enumerate() {
gene_index_map[old_idx] = Some(new_idx);
}
let results: Vec<_> = (0..n_cells)
.into_par_iter()
.map(|i| {
let start_i = sparse_data.indptr[i] as usize;
let end_i = sparse_data.indptr[i + 1] as usize;
let mut filtered_data = Vec::new();
let mut filtered_indices = Vec::new();
for idx in start_i..end_i {
let old_gene_idx = sparse_data.indices[idx] as usize;
if let Some(new_gene_idx) = gene_index_map[old_gene_idx] {
filtered_data.push(sparse_data.data[idx].clone().into());
filtered_indices.push(new_gene_idx);
}
}
let sum_data_i = filtered_data.iter().sum::<u32>() as usize;
let nnz_i = filtered_indices.len();
let to_keep_i =
(nnz_i >= cell_qc.min_unique_genes) && (sum_data_i >= cell_qc.min_lib_size);
let chunk_i = CsrCellChunk::from_data(
&filtered_data,
&filtered_indices,
i,
cell_qc.target_size,
to_keep_i,
);
(chunk_i, sum_data_i, nnz_i, to_keep_i, i)
})
.collect();
let mut res = Vec::with_capacity(n_cells);
let mut lib_size = Vec::new();
let mut nnz = Vec::new();
let mut cells_to_keep = Vec::new();
for (chunk, lib, n, keep, idx) in results {
res.push(chunk);
if keep {
lib_size.push(lib);
nnz.push(n);
cells_to_keep.push(idx);
}
}
let qc_data = CellQuality {
cell_indices: cells_to_keep,
gene_indices: genes_to_keep,
lib_size,
nnz,
};
(res, qc_data)
}
pub fn get_qc_info(&self) -> (usize, usize) {
(self.indices.len(), self.library_size)
}
pub fn filter_genes(&mut self, genes_to_keep: &[bool]) {
let mut index_map = vec![None; genes_to_keep.len()];
let mut new_idx = 0;
for (old_idx, &keep) in genes_to_keep.iter().enumerate() {
if keep {
index_map[old_idx] = Some(new_idx);
new_idx += 1;
}
}
let mut new_indices = Vec::new();
let mut new_norm = Vec::new();
let mut keep_positions = Vec::new();
for (i, &gene_idx) in self.indices.iter().enumerate() {
if let Some(new_gene_idx) = index_map[gene_idx as usize] {
new_indices.push(new_gene_idx);
new_norm.push(self.data_norm[i]);
keep_positions.push(i);
}
}
let new_raw = match &self.data_raw {
RawCounts::U16(v) => RawCounts::U16(keep_positions.iter().map(|&p| v[p]).collect()),
RawCounts::U32(v) => RawCounts::U32(keep_positions.iter().map(|&p| v[p]).collect()),
};
self.indices = new_indices;
self.data_raw = new_raw;
self.data_norm = new_norm;
}
}
#[derive(Encode, Decode, Serialize, Deserialize, Debug)]
pub struct CscGeneChunk {
pub data_raw: RawCounts,
pub data_norm: Vec<F16>,
pub avg_exp: F16,
pub nnz: usize,
pub indices: Vec<u32>,
pub original_index: usize,
pub to_keep: bool,
}
impl CscGeneChunk {
pub fn from_conversion(
data_raw: RawCounts,
data_norm: &[F16],
col_idx: &[usize],
original_index: usize,
to_keep: bool,
) -> Self {
let avg_exp = F16::from_f32(data_norm.iter().map(|v| v.to_f32()).sum::<f32>());
let nnz = data_raw.len();
Self {
data_raw,
data_norm: data_norm.to_vec(),
avg_exp,
nnz,
indices: col_idx.iter().map(|x| *x as u32).collect::<Vec<u32>>(),
original_index,
to_keep,
}
}
pub fn write_to_bytes(&self, writer: &mut impl Write) -> std::io::Result<()> {
writer.write_all(&(self.data_raw.len() as u32).to_le_bytes())?;
writer.write_all(&(self.data_norm.len() as u32).to_le_bytes())?;
writer.write_all(&(self.indices.len() as u32).to_le_bytes())?;
writer.write_all(&self.avg_exp.to_le_bytes())?;
writer.write_all(&[0, 0])?;
writer.write_all(&(self.nnz as u64).to_le_bytes())?;
writer.write_all(&(self.original_index as u64).to_le_bytes())?;
writer.write_all(&[self.to_keep as u8, self.data_raw.elem_size(), 0, 0])?;
self.data_raw.write_bytes(writer)?;
let mut data_norm_bytes = Vec::with_capacity(self.data_norm.len() * 2);
for v in &self.data_norm {
data_norm_bytes.extend_from_slice(&v.to_bits().to_le_bytes());
}
writer.write_all(&data_norm_bytes)?;
let mut row_indices_bytes = Vec::with_capacity(self.indices.len() * 4);
for i in &self.indices {
row_indices_bytes.extend_from_slice(&i.to_le_bytes());
}
writer.write_all(&row_indices_bytes)?;
Ok(())
}
pub fn read_from_buffer(buffer: &[u8]) -> Result<Self, BixverseErrors> {
if buffer.len() < CSC_CHUNK_HEADER_LEN {
return Err(BixverseErrors::ChunkBufferTooSmall {
expected: CSC_CHUNK_HEADER_LEN,
found: buffer.len(),
});
}
let header = &buffer[0..CSC_CHUNK_HEADER_LEN];
let data_raw_len =
u32::from_le_bytes([header[0], header[1], header[2], header[3]]) as usize;
let data_norm_len =
u32::from_le_bytes([header[4], header[5], header[6], header[7]]) as usize;
let row_indices_len =
u32::from_le_bytes([header[8], header[9], header[10], header[11]]) as usize;
let avg_exp = F16::from_le_bytes([header[12], header[13]]);
let nnz = u64::from_le_bytes([
header[16], header[17], header[18], header[19], header[20], header[21], header[22],
header[23],
]) as usize;
let original_index = u64::from_le_bytes([
header[24], header[25], header[26], header[27], header[28], header[29], header[30],
header[31],
]) as usize;
let to_keep = header[32] != 0;
let raw_elem_size = header[33];
let data_start = CSC_CHUNK_HEADER_LEN;
let elem_size = if raw_elem_size == RAW_ELEM_U32 {
4usize
} else {
2usize
};
let data_end = data_start + data_raw_len * elem_size;
let norm_end = data_end + data_norm_len * 2;
let total_end = norm_end + row_indices_len * 4;
if buffer.len() < total_end {
return Err(BixverseErrors::ChunkPayloadTruncated {
expected: total_end,
found: buffer.len(),
});
}
let data_raw = RawCounts::read_from_buffer(
&buffer[data_start..data_end],
data_raw_len,
raw_elem_size,
)?;
let data_norm: Vec<F16> = buffer[data_end..norm_end]
.chunks_exact(2)
.map(|c| F16::from_le_bytes(c.try_into().expect("2-byte chunk by construction")))
.collect();
let indices: Vec<u32> = buffer[norm_end..total_end]
.chunks_exact(4)
.map(|c| u32::from_le_bytes(c.try_into().expect("4-byte chunk by construction")))
.collect();
Ok(Self {
data_raw,
data_norm,
avg_exp,
nnz,
indices,
original_index,
to_keep,
})
}
pub fn filter_selected_cells(&mut self, cells_to_keep: &IndexSet<u32>) {
let cell_positions: FxHashMap<u32, usize> = self
.indices
.iter()
.enumerate()
.map(|(pos, &cell_id)| (cell_id, pos))
.collect();
let mut new_data_norm = Vec::with_capacity(cells_to_keep.len());
let mut new_row_indices = Vec::with_capacity(cells_to_keep.len());
let mut keep_positions = Vec::new();
for (new_row_idx, &cell_index) in cells_to_keep.iter().enumerate() {
if let Some(&pos) = cell_positions.get(&cell_index) {
keep_positions.push(pos);
new_data_norm.push(self.data_norm[pos]);
new_row_indices.push(new_row_idx as u32);
}
}
let new_raw = match &self.data_raw {
RawCounts::U16(v) => RawCounts::U16(keep_positions.iter().map(|&p| v[p]).collect()),
RawCounts::U32(v) => RawCounts::U32(keep_positions.iter().map(|&p| v[p]).collect()),
};
let nnz = new_row_indices.len();
self.data_raw = new_raw;
self.data_norm = new_data_norm;
self.indices = new_row_indices;
self.nnz = nnz;
}
pub fn calculate_avg_exp(&self, cells_to_keep: &IndexSet<u32>) -> (usize, f32) {
let sum: f32 = self
.indices
.iter()
.zip(self.data_norm.iter())
.filter(|(cell_idx, _)| cells_to_keep.contains(*cell_idx))
.map(|(_, val)| val.to_f32())
.sum();
let avg = sum / cells_to_keep.len() as f32;
(self.original_index, avg)
}
pub fn to_sparse_axis(&self, n_cells: usize) -> SparseAxis<u32, f32> {
let raw_u32: Vec<u32> = self.data_raw.iter().collect();
SparseAxis::new_csc(
self.indices.iter().map(|x| *x as usize).collect(),
raw_u32,
Some(self.data_norm.iter().map(|x| x.to_f32()).collect()),
n_cells,
)
}
}
#[derive(Encode, Decode, Serialize, Deserialize, Clone)]
pub struct SparseDataHeader {
pub total_cells: usize,
pub total_genes: usize,
pub cell_based: bool,
pub no_chunks: usize,
pub chunk_offsets: Vec<u64>,
pub index_map: FxHashMap<usize, usize>,
}
const FILE_MAGIC: &[u8; 8] = b"SCRNASEQ";
#[repr(C)]
#[derive(Encode, Decode, Serialize, Deserialize)]
struct FileHeader {
magic: [u8; 8],
version: u32,
main_header_offset: u64,
cell_based: bool,
target_size: f32,
_reserved_1: [u8; 28],
_reserved_2: [u8; 3],
}
impl FileHeader {
fn new(cell_based: bool, target_size: f32) -> Self {
Self {
magic: *FILE_MAGIC,
version: SC_FILE_VERSION,
main_header_offset: 0,
cell_based,
target_size,
_reserved_1: [0; 28],
_reserved_2: [0; 3],
}
}
}
pub struct CellGeneSparseWriter {
header: SparseDataHeader,
writer: BufWriter<File>,
chunks_start_pos: u64,
cell_based: bool,
chunks_since_flush: usize,
flush_frequency: usize,
target_size: f32,
}
impl CellGeneSparseWriter {
pub fn new<P: AsRef<Path>>(
path_f: P,
cell_based: bool,
total_cells: usize,
total_genes: usize,
target_size: f32,
) -> Result<Self, BixverseErrors> {
let file = File::create(path_f)?;
let mut writer = BufWriter::with_capacity(128 * 1024 * 1024, file);
let file_header = FileHeader::new(cell_based, target_size);
let file_header_enc = encode_to_vec(&file_header, config::standard())
.map_err(|_| BixverseErrors::HeaderEncodeFailed)?;
if file_header_enc.len() < 64 {
writer.write_all(&file_header_enc)?;
writer.write_all(&vec![0u8; 64 - file_header_enc.len()])?;
} else {
writer.write_all(&file_header_enc[..64])?;
}
writer.flush()?;
let chunks_start_pos = 64;
let flush_frequency = if cell_based {
CELL_FLUSH_FREQUENCY
} else {
GENE_FLUSH_FREQUENCY
};
let header = SparseDataHeader {
total_cells,
total_genes,
cell_based,
no_chunks: 0,
chunk_offsets: Vec::new(),
index_map: FxHashMap::default(),
};
Ok(Self {
header,
writer,
chunks_start_pos,
cell_based,
chunks_since_flush: 0_usize,
flush_frequency,
target_size,
})
}
fn check_mode(&self, want_cell_based: bool) -> Result<(), BixverseErrors> {
if self.cell_based == want_cell_based {
return Ok(());
}
let label = |b: bool| if b { "cell-based" } else { "gene-based" };
Err(BixverseErrors::ReaderModeMismatch {
actual: label(self.cell_based),
requested: label(want_cell_based),
})
}
fn append_chunk(&mut self, buffer: &[u8], original_index: usize) -> Result<(), BixverseErrors> {
let current_pos = self.writer.stream_position()?;
let chunk_offset = current_pos - self.chunks_start_pos;
self.header.chunk_offsets.push(chunk_offset);
self.header
.index_map
.insert(original_index, self.header.no_chunks);
let compressed = compress_prepend_size(buffer);
self.writer
.write_all(&(compressed.len() as u64).to_le_bytes())?;
self.writer.write_all(&compressed)?;
self.header.no_chunks += 1;
self.chunks_since_flush += 1;
if self.chunks_since_flush >= self.flush_frequency {
self.writer.flush()?;
self.chunks_since_flush = 0;
}
Ok(())
}
pub fn write_cell_chunk(&mut self, cell_chunk: CsrCellChunk) -> Result<(), BixverseErrors> {
self.check_mode(true)?;
let mut buffer = Vec::new();
cell_chunk.write_to_bytes(&mut buffer)?;
self.append_chunk(&buffer, cell_chunk.original_index)
}
pub fn write_gene_chunk(&mut self, gene_chunk: CscGeneChunk) -> Result<(), BixverseErrors> {
self.check_mode(false)?;
let mut buffer = Vec::new();
gene_chunk.write_to_bytes(&mut buffer)?;
self.append_chunk(&buffer, gene_chunk.original_index)
}
pub fn finalise(mut self) -> Result<(), BixverseErrors> {
let main_header_offset = self.writer.stream_position()?;
let header_data = encode_to_vec(&self.header, config::standard())
.map_err(|_| BixverseErrors::HeaderEncodeFailed)?;
let header_size = header_data.len() as u64;
self.writer.write_all(&header_size.to_le_bytes())?;
self.writer.write_all(&header_data)?;
self.writer.seek(SeekFrom::Start(0))?;
let mut file_header = FileHeader::new(self.cell_based, self.target_size);
file_header.main_header_offset = main_header_offset;
let file_header_enc = encode_to_vec(&file_header, config::standard())
.map_err(|_| BixverseErrors::HeaderEncodeFailed)?;
if file_header_enc.len() < 64 {
self.writer.write_all(&file_header_enc)?;
self.writer
.write_all(&vec![0u8; 64 - file_header_enc.len()])?;
} else {
self.writer.write_all(&file_header_enc[..64])?;
}
self.writer.flush()?;
Ok(())
}
pub fn update_header_no_cells(&mut self, no_cells: usize) {
self.header.total_cells = no_cells;
}
pub fn update_header_no_genes(&mut self, no_genes: usize) {
self.header.total_genes = no_genes;
}
}
pub trait SingleCellReading: Send + Sync {
fn read_cells_parallel(&self, indices: &[usize]) -> Result<Vec<CsrCellChunk>, BixverseErrors>;
fn read_gene_parallel(&self, indices: &[usize]) -> Result<Vec<CscGeneChunk>, BixverseErrors>;
fn get_header(&self) -> SparseDataHeader;
fn is_cell_based(&self) -> bool;
fn is_gene_based(&self) -> bool {
!self.is_cell_based()
}
fn target_size(&self) -> Option<f32> {
None
}
fn read_cell(&self, index: usize) -> Result<CsrCellChunk, BixverseErrors> {
self.read_cells_parallel(&[index])?
.into_iter()
.next()
.ok_or(BixverseErrors::ChunkIndexNotFound(index))
}
fn read_gene(&self, index: usize) -> Result<CscGeneChunk, BixverseErrors> {
self.read_gene_parallel(&[index])?
.into_iter()
.next()
.ok_or(BixverseErrors::ChunkIndexNotFound(index))
}
fn read_cells_range(
&self,
start: usize,
end: usize,
) -> Result<Vec<CsrCellChunk>, BixverseErrors> {
let indices: Vec<usize> = (start..end).collect();
self.read_cells_parallel(&indices)
}
fn get_all_cells(&self) -> Result<Vec<CsrCellChunk>, BixverseErrors> {
let indices: Vec<usize> = (0..self.get_header().total_cells).collect();
self.read_cells_parallel(&indices)
}
fn get_all_genes(&self) -> Result<Vec<CscGeneChunk>, BixverseErrors> {
let indices: Vec<usize> = (0..self.get_header().total_genes).collect();
self.read_gene_parallel(&indices)
}
fn read_gene_parallel_filtered(
&self,
indices: &[usize],
cells_to_keep: &IndexSet<u32>,
) -> Result<Vec<CscGeneChunk>, BixverseErrors> {
let mut chunks = self.read_gene_parallel(indices)?;
chunks
.par_iter_mut()
.for_each(|chunk| chunk.filter_selected_cells(cells_to_keep));
Ok(chunks)
}
fn read_cell_library_sizes(&self, indices: &[usize]) -> Result<Vec<usize>, BixverseErrors> {
Ok(self
.read_cells_parallel(indices)?
.iter()
.map(|chunk| chunk.library_size)
.collect())
}
fn read_gene_nnz(&self, indices: &[usize]) -> Result<Vec<usize>, BixverseErrors> {
Ok(self
.read_gene_parallel(indices)?
.iter()
.map(|chunk| chunk.nnz)
.collect())
}
fn get_all_gene_nnz(&self) -> Result<Vec<usize>, BixverseErrors> {
let indices: Vec<usize> = (0..self.get_header().total_genes).collect();
self.read_gene_nnz(&indices)
}
fn get_clr_offsets(
&self,
indices: &[usize],
batch_size: Option<usize>,
) -> Result<Vec<f64>, BixverseErrors> {
if !self.is_cell_based() {
return Err(BixverseErrors::ReaderModeMismatch {
actual: "gene-based",
requested: "cell-based",
});
}
let batch_size = batch_size.unwrap_or(CELL_BATCH_SIZE);
let n_genes = self.get_header().total_genes as f64;
let mut offsets = Vec::with_capacity(indices.len());
for batch in indices.chunks(batch_size) {
let cells = self.read_cells_parallel(batch)?;
let batch_offsets: Vec<f64> = cells
.par_iter()
.map(|chunk| {
let inv_s = 1.0_f64 / chunk.library_size as f64;
let sum: f64 = chunk
.data_raw
.iter()
.map(|c| (c as f64 * inv_s).ln_1p())
.sum();
sum / n_genes
})
.collect();
offsets.extend_from_slice(&batch_offsets);
}
Ok(offsets)
}
}
pub fn peek_target_size<P: AsRef<Path>>(f_path: P) -> Result<Option<f32>, BixverseErrors> {
use std::io::Read;
let mut buffer = [0u8; 64];
File::open(f_path)?.read_exact(&mut buffer)?;
let (file_header, _) = decode_from_slice::<FileHeader, _>(&buffer, config::standard())
.map_err(|_| BixverseErrors::HeaderDecodeFailed)?;
Ok((file_header.target_size != 0.0).then_some(file_header.target_size))
}
pub struct ParallelSparseReader {
header: SparseDataHeader,
mmap: Arc<memmap2::Mmap>,
chunks_start: u64,
target_size: f32,
}
impl ParallelSparseReader {
pub fn new(f_path: &str) -> Result<Self, BixverseErrors> {
let file = File::open(f_path)?;
let file_size = file.metadata()?.len();
let mmap = unsafe {
let mut opts = MmapOptions::new();
if file_size <= 8 * 1024 * 1024 * 1024 {
opts.populate();
}
opts.map(&file)?
};
#[cfg(unix)]
mmap.advise(memmap2::Advice::Random)?;
if mmap.len() < 64 {
return Err(BixverseErrors::HeaderDecodeFailed);
}
let file_header_bytes = &mmap[0..64];
let (file_header, _) =
decode_from_slice::<FileHeader, _>(file_header_bytes, config::standard())
.map_err(|_| BixverseErrors::HeaderDecodeFailed)?;
if &file_header.magic != FILE_MAGIC {
return Err(BixverseErrors::HeaderDecodeFailed);
}
if file_header.version != SC_FILE_VERSION {
return Err(BixverseErrors::FileVersionMismatch {
expected: SC_FILE_VERSION,
found: file_header.version,
});
}
let main_header_offset = file_header.main_header_offset as usize;
if main_header_offset.saturating_add(8) > mmap.len() {
return Err(BixverseErrors::HeaderDecodeFailed);
}
let header_size = u64::from_le_bytes(
mmap[main_header_offset..main_header_offset + 8]
.try_into()
.expect("8-byte slice, bounds checked above"),
) as usize;
let header_end = main_header_offset
.saturating_add(8)
.saturating_add(header_size);
if header_end > mmap.len() {
return Err(BixverseErrors::HeaderDecodeFailed);
}
let header_bytes = &mmap[main_header_offset + 8..header_end];
let (header, _) =
decode_from_slice::<SparseDataHeader, _>(header_bytes, config::standard())
.map_err(|_| BixverseErrors::HeaderDecodeFailed)?;
Ok(Self {
header,
mmap: Arc::new(mmap),
chunks_start: 64,
target_size: file_header.target_size,
})
}
fn decompress_chunk(&self, original_index: usize) -> Result<Vec<u8>, BixverseErrors> {
let chunk_index = *self
.header
.index_map
.get(&original_index)
.ok_or(BixverseErrors::ChunkIndexNotFound(original_index))?;
let chunk_offset = (self.chunks_start + self.header.chunk_offsets[chunk_index]) as usize;
let compressed_size = u64::from_le_bytes(
self.mmap[chunk_offset..chunk_offset + 8]
.try_into()
.expect("8-byte slice by construction"),
) as usize;
let compressed = &self.mmap[chunk_offset + 8..chunk_offset + 8 + compressed_size];
decompress_size_prepended(compressed)
.map_err(|_| BixverseErrors::ChunkDecompressionFailed(chunk_offset as u64))
}
}
impl SingleCellReading for ParallelSparseReader {
fn read_cells_parallel(&self, indices: &[usize]) -> Result<Vec<CsrCellChunk>, BixverseErrors> {
if !self.is_cell_based() {
return Err(BixverseErrors::ReaderModeMismatch {
actual: "gene-based",
requested: "cell-based",
});
}
indices
.par_iter()
.map(|&original_index| {
let decompressed = self.decompress_chunk(original_index)?;
CsrCellChunk::read_from_buffer(&decompressed)
})
.collect()
}
fn read_gene_parallel(&self, indices: &[usize]) -> Result<Vec<CscGeneChunk>, BixverseErrors> {
if !self.is_gene_based() {
return Err(BixverseErrors::ReaderModeMismatch {
actual: "cell-based",
requested: "gene-based",
});
}
indices
.par_iter()
.map(|&original_index| {
let decompressed = self.decompress_chunk(original_index)?;
CscGeneChunk::read_from_buffer(&decompressed)
})
.collect()
}
fn read_gene_parallel_filtered(
&self,
indices: &[usize],
cells_to_keep: &IndexSet<u32>,
) -> Result<Vec<CscGeneChunk>, BixverseErrors> {
if !self.is_gene_based() {
return Err(BixverseErrors::ReaderModeMismatch {
actual: "cell-based",
requested: "gene-based",
});
}
indices
.par_iter()
.map(|&original_index| {
let decompressed = self.decompress_chunk(original_index)?;
let mut chunk = CscGeneChunk::read_from_buffer(&decompressed)?;
chunk.filter_selected_cells(cells_to_keep);
Ok(chunk)
})
.collect()
}
fn get_header(&self) -> SparseDataHeader {
self.header.clone()
}
fn read_cell_library_sizes(&self, indices: &[usize]) -> Result<Vec<usize>, BixverseErrors> {
if !self.is_cell_based() {
return Err(BixverseErrors::ReaderModeMismatch {
actual: "gene-based",
requested: "cell-based",
});
}
indices
.par_iter()
.map(|&original_index| {
let decompressed = self.decompress_chunk(original_index)?;
Ok(u64::from_le_bytes([
decompressed[12],
decompressed[13],
decompressed[14],
decompressed[15],
decompressed[16],
decompressed[17],
decompressed[18],
decompressed[19],
]) as usize)
})
.collect()
}
fn read_gene_nnz(&self, indices: &[usize]) -> Result<Vec<usize>, BixverseErrors> {
if !self.is_gene_based() {
return Err(BixverseErrors::ReaderModeMismatch {
actual: "cell-based",
requested: "gene-based",
});
}
indices
.par_iter()
.map(|&original_index| {
let decompressed = self.decompress_chunk(original_index)?;
Ok(u64::from_le_bytes([
decompressed[16],
decompressed[17],
decompressed[18],
decompressed[19],
decompressed[20],
decompressed[21],
decompressed[22],
decompressed[23],
]) as usize)
})
.collect()
}
fn is_cell_based(&self) -> bool {
self.header.cell_based
}
fn target_size(&self) -> Option<f32> {
(self.target_size != 0.0).then_some(self.target_size)
}
}
#[derive(Clone, Debug, Copy)]
pub enum DataLayerReturn {
BothLayers,
Raw,
Norm,
}
pub fn from_gene_chunks<T>(
chunks: Vec<CscGeneChunk>,
data_layer: &DataLayerReturn,
n_cells: usize,
) -> Result<CompressedSparseData2<T, f32>, BixverseErrors>
where
T: BixverseNumeric + FromPrimitive,
{
let n_genes = chunks.len();
let keep_raw = matches!(
data_layer,
DataLayerReturn::BothLayers | DataLayerReturn::Raw
);
let keep_norm = matches!(
data_layer,
DataLayerReturn::BothLayers | DataLayerReturn::Norm
);
let mut data: Vec<T> = Vec::new();
let mut data_2: Vec<f32> = Vec::new();
let mut indices: Vec<usize> = Vec::new();
let mut indptr = Vec::with_capacity(n_genes + 1);
indptr.push(0);
for chunk in chunks {
if keep_raw {
for val in chunk.data_raw.iter() {
data.push(T::from_u32(val).ok_or(BixverseErrors::RawCountOverflow {
value: val,
target_type: std::any::type_name::<T>(),
})?);
}
}
if keep_norm {
for &val in &chunk.data_norm {
data_2.push(val.to_f32());
}
}
for &idx in &chunk.indices {
indices.push(idx as usize);
}
indptr.push(indices.len());
}
Ok(CompressedSparseData2 {
data,
indices: indices.index_cast(),
indptr: indptr.index_cast(),
cs_type: CompressedSparseFormat::Csc,
data_2: if keep_norm { Some(data_2) } else { None },
shape: (n_cells, n_genes),
})
}
pub fn from_cell_chunks<T>(
chunks: &[CsrCellChunk],
data_layer: &DataLayerReturn,
n_genes: usize,
) -> Result<CompressedSparseData2<T, f32>, BixverseErrors>
where
T: BixverseNumeric + FromPrimitive,
{
let n_cells = chunks.len();
let keep_raw = matches!(
data_layer,
DataLayerReturn::BothLayers | DataLayerReturn::Raw
);
let keep_norm = matches!(
data_layer,
DataLayerReturn::BothLayers | DataLayerReturn::Norm
);
let mut data: Vec<T> = Vec::new();
let mut data_2: Vec<f32> = Vec::new();
let mut indices: Vec<usize> = Vec::new();
let mut indptr = Vec::with_capacity(n_cells + 1);
indptr.push(0);
for chunk in chunks {
if keep_raw {
for val in chunk.data_raw.iter() {
data.push(T::from_u32(val).ok_or(BixverseErrors::RawCountOverflow {
value: val,
target_type: std::any::type_name::<T>(),
})?);
}
}
if keep_norm {
for &val in &chunk.data_norm {
data_2.push(val.to_f32());
}
}
for &idx in &chunk.indices {
indices.push(idx as usize);
}
indptr.push(indices.len());
}
Ok(CompressedSparseData2 {
data,
indices: indices.index_cast(),
indptr: indptr.index_cast(),
cs_type: CompressedSparseFormat::Csr,
data_2: if keep_norm { Some(data_2) } else { None },
shape: (n_cells, n_genes),
})
}
#[cfg(test)]
mod tests {
use super::*;
struct TempBin(std::path::PathBuf);
impl Drop for TempBin {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
impl TempBin {
fn new(name: &str) -> Self {
Self(std::env::temp_dir().join(format!("bixverse_data_io_{name}.bin")))
}
fn path(&self) -> &str {
self.0.to_str().expect("temp path is valid UTF-8")
}
}
fn cell_chunk(raw: &[u32], indices: &[u32], original_index: usize) -> CsrCellChunk {
CsrCellChunk {
data_raw: RawCounts::from_u32_auto(raw),
data_norm: raw
.iter()
.map(|&v| F16::from(f16::from_f32(v as f32)))
.collect(),
library_size: raw.iter().map(|&v| v as usize).sum(),
indices: indices.to_vec(),
original_index,
to_keep: true,
}
}
fn gene_chunk(raw: &[u32], indices: &[u32], original_index: usize) -> CscGeneChunk {
CscGeneChunk {
data_raw: RawCounts::from_u32_auto(raw),
data_norm: raw
.iter()
.map(|&v| F16::from(f16::from_f32(v as f32)))
.collect(),
avg_exp: F16::from(f16::from_f32(1.0)),
nnz: raw.len(),
indices: indices.to_vec(),
original_index,
to_keep: true,
}
}
#[test]
fn test_cell_chunk_round_trip_preserves_fields() {
let raw = [1_u32, 7, 42, 65_535, 3];
let indices = [0_u32, 5, 9, 100_000, 300_000];
let chunk = cell_chunk(&raw, &indices, 17);
let mut buffer = Vec::new();
chunk.write_to_bytes(&mut buffer).expect("serialise");
let back = CsrCellChunk::read_from_buffer(&buffer).expect("deserialise");
assert_eq!(back.data_raw.iter().collect::<Vec<_>>(), raw.to_vec());
assert_eq!(back.indices, indices.to_vec());
assert_eq!(back.library_size, chunk.library_size);
assert_eq!(back.original_index, 17);
assert!(back.to_keep);
let bits: Vec<u16> = back.data_norm.iter().map(|v| v.to_bits()).collect();
let want: Vec<u16> = chunk.data_norm.iter().map(|v| v.to_bits()).collect();
assert_eq!(bits, want);
}
#[test]
fn test_gene_chunk_round_trip_preserves_fields() {
let raw = [2_u32, 0, 99, 70_000];
let indices = [1_u32, 4, 8, 12];
let chunk = gene_chunk(&raw, &indices, 3);
let mut buffer = Vec::new();
chunk.write_to_bytes(&mut buffer).expect("serialise");
let back = CscGeneChunk::read_from_buffer(&buffer).expect("deserialise");
assert_eq!(back.data_raw.iter().collect::<Vec<_>>(), raw.to_vec());
assert_eq!(back.indices, indices.to_vec());
assert_eq!(back.nnz, 4);
assert_eq!(back.original_index, 3);
assert_eq!(back.avg_exp.to_bits(), chunk.avg_exp.to_bits());
}
#[test]
fn test_gene_chunk_short_buffer_errors_instead_of_panicking() {
for len in CSR_CHUNK_HEADER_LEN..CSC_CHUNK_HEADER_LEN {
let buffer = vec![0_u8; len];
match CscGeneChunk::read_from_buffer(&buffer) {
Err(BixverseErrors::ChunkBufferTooSmall { expected, found }) => {
assert_eq!(expected, CSC_CHUNK_HEADER_LEN);
assert_eq!(found, len);
}
other => panic!("expected ChunkBufferTooSmall for len {len}, got {other:?}"),
}
}
}
#[test]
fn test_chunk_payload_truncation_is_caught() {
let chunk = cell_chunk(&[1, 2, 3], &[0, 1, 2], 0);
let mut buffer = Vec::new();
chunk.write_to_bytes(&mut buffer).expect("serialise");
buffer[0..4].copy_from_slice(&12_u32.to_le_bytes());
assert!(matches!(
CsrCellChunk::read_from_buffer(&buffer),
Err(BixverseErrors::ChunkPayloadTruncated { .. })
));
let gene = gene_chunk(&[1, 2, 3], &[0, 1, 2], 0);
let mut buffer = Vec::new();
gene.write_to_bytes(&mut buffer).expect("serialise");
buffer[8..12].copy_from_slice(&64_u32.to_le_bytes());
assert!(matches!(
CscGeneChunk::read_from_buffer(&buffer),
Err(BixverseErrors::ChunkPayloadTruncated { .. })
));
}
#[test]
fn test_chunk_round_trip_with_unaligned_index_offset() {
let raw = [1_u32, 2, 3];
let indices = [7_u32, 8, 9];
let chunk = cell_chunk(&raw, &indices, 0);
assert_eq!(chunk.data_raw.elem_size(), RAW_ELEM_U16);
let mut buffer = Vec::new();
chunk.write_to_bytes(&mut buffer).expect("serialise");
assert_eq!(buffer.len() % 4, 0);
let back = CsrCellChunk::read_from_buffer(&buffer).expect("deserialise");
assert_eq!(back.indices, indices.to_vec());
assert_eq!(back.data_raw.iter().collect::<Vec<_>>(), raw.to_vec());
}
#[test]
fn test_raw_counts_element_size_discriminant() {
let payload = [1_u8, 0, 2, 0];
let legacy = RawCounts::read_from_buffer(&payload, 2, 0).expect("legacy parses as u16");
assert_eq!(legacy.iter().collect::<Vec<_>>(), vec![1, 2]);
let explicit =
RawCounts::read_from_buffer(&payload, 2, RAW_ELEM_U16).expect("explicit u16 parses");
assert_eq!(explicit.iter().collect::<Vec<_>>(), vec![1, 2]);
assert!(matches!(
RawCounts::read_from_buffer(&payload, 2, 3),
Err(BixverseErrors::RawElemSizeInvalid(3))
));
}
#[test]
fn test_large_raw_count_survives_round_trip() {
let raw = [70_000_u32, 1];
let chunk = cell_chunk(&raw, &[0, 1], 0);
assert_eq!(chunk.data_raw.elem_size(), RAW_ELEM_U32);
let mut buffer = Vec::new();
chunk.write_to_bytes(&mut buffer).expect("serialise");
let back = CsrCellChunk::read_from_buffer(&buffer).expect("deserialise");
let csr = from_cell_chunks::<u32>(&[back], &DataLayerReturn::Raw, 2).expect("assemble");
assert_eq!(csr.data, vec![70_000_u32, 1]);
}
#[test]
fn test_narrow_target_type_reports_overflow() {
let chunk = gene_chunk(&[70_000, 1], &[0, 1], 0);
match from_gene_chunks::<u16>(vec![chunk], &DataLayerReturn::Raw, 2) {
Err(BixverseErrors::RawCountOverflow { value, .. }) => assert_eq!(value, 70_000),
other => panic!("expected RawCountOverflow, got {:?}", other.map(|_| ())),
}
}
#[test]
fn test_cell_based_file_round_trip() {
let temp = TempBin::new("cell_round_trip");
let chunks = vec![
cell_chunk(&[5, 70_000], &[0, 2], 0),
cell_chunk(&[1, 2, 3], &[0, 1, 2], 1),
];
let mut writer =
CellGeneSparseWriter::new(temp.path(), true, 2, 3, 1e4).expect("writer opens");
for chunk in chunks {
writer.write_cell_chunk(chunk).expect("write");
}
writer.finalise().expect("finalise");
let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
assert_eq!(reader.target_size(), Some(1e4));
assert!(reader.is_cell_based());
let back = reader.read_cells_parallel(&[0, 1]).expect("read");
assert_eq!(back.len(), 2);
assert_eq!(back[0].data_raw.iter().collect::<Vec<_>>(), vec![5, 70_000]);
assert_eq!(back[1].indices, vec![0, 1, 2]);
}
fn patch_file_header(path: &str, edit: impl FnOnce(&mut FileHeader)) {
let mut bytes = std::fs::read(path).expect("read back");
let (mut header, _) = decode_from_slice::<FileHeader, _>(&bytes[0..64], config::standard())
.expect("header decodes");
edit(&mut header);
let encoded = encode_to_vec(&header, config::standard()).expect("header encodes");
let mut slot = [0_u8; 64];
slot[..encoded.len()].copy_from_slice(&encoded);
bytes[0..64].copy_from_slice(&slot);
std::fs::write(path, bytes).expect("write back");
}
fn write_valid_file(path: &str) {
let mut writer = CellGeneSparseWriter::new(path, true, 1, 3, 1e4).expect("writer opens");
writer
.write_cell_chunk(cell_chunk(&[1, 2], &[0, 1], 0))
.expect("write");
writer.finalise().expect("finalise");
}
#[test]
fn test_reader_rejects_a_version_mismatch() {
let temp = TempBin::new("version_mismatch");
write_valid_file(temp.path());
patch_file_header(temp.path(), |h| h.version = SC_FILE_VERSION + 1);
match ParallelSparseReader::new(temp.path()) {
Err(BixverseErrors::FileVersionMismatch { expected, found }) => {
assert_eq!(expected, SC_FILE_VERSION);
assert_eq!(found, SC_FILE_VERSION + 1);
}
Err(other) => panic!("expected a version mismatch, got {other:?}"),
Ok(_) => panic!("a mismatched version must not open"),
}
}
#[test]
fn test_reader_rejects_a_bad_magic() {
let temp = TempBin::new("bad_magic");
write_valid_file(temp.path());
patch_file_header(temp.path(), |h| h.magic = *b"NOTOURS!");
assert!(
ParallelSparseReader::new(temp.path()).is_err(),
"a wrong magic string must not open"
);
}
#[test]
fn test_reader_rejects_a_main_header_offset_past_eof() {
let temp = TempBin::new("offset_past_eof");
write_valid_file(temp.path());
patch_file_header(temp.path(), |h| h.main_header_offset = u64::MAX / 2);
assert!(
ParallelSparseReader::new(temp.path()).is_err(),
"an out-of-range main header offset must not open"
);
}
#[test]
fn test_reader_rejects_a_truncated_file() {
let temp = TempBin::new("truncated");
write_valid_file(temp.path());
let bytes = std::fs::read(temp.path()).expect("read back");
std::fs::write(temp.path(), &bytes[..bytes.len() / 2]).expect("truncate");
assert!(
ParallelSparseReader::new(temp.path()).is_err(),
"a truncated file must not open"
);
}
#[test]
fn test_gene_based_file_round_trip() {
let temp = TempBin::new("gene_round_trip");
let mut writer =
CellGeneSparseWriter::new(temp.path(), false, 4, 2, 1e5).expect("writer opens");
writer
.write_gene_chunk(gene_chunk(&[9, 8], &[0, 3], 0))
.expect("write");
writer
.write_gene_chunk(gene_chunk(&[100_000], &[2], 1))
.expect("write");
writer.finalise().expect("finalise");
let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
assert_eq!(reader.target_size(), Some(1e5));
assert!(reader.is_gene_based());
let back = reader.read_gene_parallel(&[0, 1]).expect("read");
assert_eq!(back[0].data_raw.iter().collect::<Vec<_>>(), vec![9, 8]);
assert_eq!(back[1].data_raw.iter().collect::<Vec<_>>(), vec![100_000]);
}
#[test]
fn test_writer_rejects_wrong_chunk_orientation() {
let temp = TempBin::new("mode_mismatch");
let mut writer =
CellGeneSparseWriter::new(temp.path(), true, 1, 1, 0.0).expect("writer opens");
match writer.write_gene_chunk(gene_chunk(&[1], &[0], 0)) {
Err(BixverseErrors::ReaderModeMismatch { actual, requested }) => {
assert_eq!(actual, "cell-based");
assert_eq!(requested, "gene-based");
}
other => panic!("expected ReaderModeMismatch, got {other:?}"),
}
}
#[test]
fn test_raw_only_file_reports_no_target_size() {
let temp = TempBin::new("no_target_size");
let mut writer =
CellGeneSparseWriter::new(temp.path(), true, 1, 2, 0.0).expect("writer opens");
writer
.write_cell_chunk(cell_chunk(&[1, 2], &[0, 1], 0))
.expect("write");
writer.finalise().expect("finalise");
let reader = ParallelSparseReader::new(temp.path()).expect("reader opens");
assert_eq!(reader.target_size(), None);
assert_eq!(peek_target_size(temp.path()).expect("peek"), None);
}
#[derive(Encode, Decode, Serialize, Deserialize)]
struct LegacyFileHeader {
magic: [u8; 8],
version: u32,
main_header_offset: u64,
cell_based: bool,
_reserved_1: [u8; 32],
_reserved_2: [u8; 3],
}
fn encode_slot<T: Serialize>(header: &T) -> [u8; 64] {
let encoded = encode_to_vec(header, config::standard()).expect("encode");
assert!(encoded.len() <= 64, "header must fit the fixed slot");
let mut slot = [0_u8; 64];
slot[..encoded.len()].copy_from_slice(&encoded);
slot
}
#[test]
fn test_legacy_header_decodes_with_zero_target_size() {
let legacy = LegacyFileHeader {
magic: *b"SCRNASEQ",
version: SC_FILE_VERSION,
main_header_offset: 123_456,
cell_based: true,
_reserved_1: [0; 32],
_reserved_2: [0; 3],
};
let slot = encode_slot(&legacy);
let (decoded, _) =
decode_from_slice::<FileHeader, _>(&slot, config::standard()).expect("decode as v3");
assert_eq!(decoded.magic, *b"SCRNASEQ");
assert_eq!(decoded.version, SC_FILE_VERSION);
assert_eq!(decoded.main_header_offset, 123_456);
assert!(decoded.cell_based);
assert_eq!(decoded.target_size, 0.0);
}
#[test]
fn test_new_header_still_decodes_on_the_old_layout() {
let mut header = FileHeader::new(false, 1e4);
header.main_header_offset = 987_654_321;
let slot = encode_slot(&header);
let (decoded, _) = decode_from_slice::<LegacyFileHeader, _>(&slot, config::standard())
.expect("decode as legacy");
assert_eq!(decoded.magic, *b"SCRNASEQ");
assert_eq!(decoded.version, SC_FILE_VERSION);
assert_eq!(decoded.main_header_offset, 987_654_321);
assert!(!decoded.cell_based);
}
#[test]
fn test_target_size_survives_header_round_trip() {
let slot = encode_slot(&FileHeader::new(true, 1e5));
let (decoded, _) =
decode_from_slice::<FileHeader, _>(&slot, config::standard()).expect("decode");
assert_eq!(decoded.target_size, 1e5);
}
}