use std::sync::Arc;
use log::{debug, info, warn};
use crate::convert::try_open_or_convert;
use crate::sparse_io_vector::SparseIoVec;
use legume_numeric::matrix::common_io::{self, basename, read_lines};
use rustc_hash::FxHashSet;
use crate::aux::feature_names::FeatureNameKind;
use crate::sparse_io_vector::{ColumnAlignment, RowAlignment};
const MULTIMODAL_HINT_DISJOINTNESS_FRACTION: f64 = 0.5;
#[derive(Default)]
pub struct ReadSharedRowsArgs {
pub data_files: Vec<Box<str>>,
pub batch_files: Option<Vec<Box<str>>>,
pub preload: bool,
pub feature_kind: Option<FeatureNameKind>,
pub row_alignment: RowAlignment,
pub column_alignment: ColumnAlignment,
pub qc: Option<crate::qc_lib::QcConfig>,
pub keep_empty_barcodes: bool,
pub qc_exempt_files: Option<Vec<bool>>,
pub qc_block_size: Option<usize>,
pub qc_report_out: Option<Box<str>>,
pub per_file_feature_suffix: Option<Vec<Box<str>>>,
pub per_file_barcode_suffix: Option<Vec<Option<Box<str>>>>,
}
pub struct SparseDataWithBatch {
pub data: SparseIoVec,
pub batch: Vec<Box<str>>,
pub output_keep_idx: Option<Vec<usize>>,
}
pub fn read_data_on_shared_rows(args: ReadSharedRowsArgs) -> anyhow::Result<SparseDataWithBatch> {
let attach_data_name = args.data_files.len() > 1;
type OpenedBackend = Box<dyn crate::sparse_io::SparseIo<IndexIter = Vec<usize>>>;
let mut opened: Vec<(Box<str>, OpenedBackend)> = Vec::with_capacity(args.data_files.len());
for data_file in args.data_files.iter() {
info!("Importing data file: {}", data_file);
let mut data = try_open_or_convert(data_file)?;
if args.preload {
data.preload_columns()?;
}
opened.push((data_file.clone(), data));
}
let attach_data_name = attach_data_name && args.column_alignment == ColumnAlignment::Disjoint;
let mut data_vec = SparseIoVec::new()
.with_row_alignment(args.row_alignment)
.expect("with_row_alignment on empty SparseIoVec")
.with_column_alignment(args.column_alignment)
.expect("with_column_alignment on empty SparseIoVec");
if let Some(suffix) = args.per_file_feature_suffix.clone() {
anyhow::ensure!(
suffix.len() == args.data_files.len(),
"per_file_feature_suffix has {} entries but {} data files were given",
suffix.len(),
args.data_files.len(),
);
data_vec = data_vec
.with_per_backend_row_suffix(suffix)
.expect("with_per_backend_row_suffix on empty SparseIoVec");
}
if let Some(sfx) = args.per_file_barcode_suffix.as_ref() {
anyhow::ensure!(
sfx.len() == args.data_files.len(),
"per_file_barcode_suffix has {} entries but {} data files were given",
sfx.len(),
args.data_files.len(),
);
}
use crate::aux::feature_names::FeatureNameKind;
let needs_names = args.feature_kind.is_none()
|| args
.feature_kind
.as_ref()
.is_some_and(|k| k.needs_global_pass());
let mut file_ends: Vec<usize> = Vec::with_capacity(opened.len());
let all_names: Option<Vec<Box<str>>> = if needs_names {
let mut acc: Vec<Box<str>> = Vec::new();
for (_, d) in opened.iter() {
acc.extend(d.row_names()?);
file_ends.push(acc.len());
}
Some(acc)
} else {
None
};
let kind_was_auto = args.feature_kind.is_none();
let resolved_kind: FeatureNameKind = match args.feature_kind.clone() {
Some(k) => k,
None => {
let names = all_names.as_ref().expect("peeked when auto");
let per_file: Vec<FeatureNameKind> = std::iter::once(0)
.chain(file_ends.iter().copied())
.zip(file_ends.iter().copied())
.map(|(start, end)| FeatureNameKind::auto_detect(&names[start..end]))
.collect();
let k = FeatureNameKind::reconcile(&per_file);
debug!(
"Row alignment: auto-detected feature name kind → {:?} (per file: {:?}; {} rows)",
k,
per_file,
names.len()
);
k
}
};
match &resolved_kind {
FeatureNameKind::Mixed => {
let names = all_names.as_ref().expect("peeked for Mixed").clone();
debug!(
"Row alignment: building MIXED-kind canonical map over {} names \
across {} file(s)",
names.len(),
opened.len()
);
let canon = crate::aux::feature_names::build_mixed_kind_canonicalizer(&names);
data_vec = data_vec
.with_row_canonicalizer(move |name| canon(name))
.expect("with_row_canonicalizer on empty SparseIoVec");
}
FeatureNameKind::Locus {
merge_overlapping: true,
} => {
let names = all_names
.as_ref()
.expect("peeked for Locus merge_overlapping")
.clone();
debug!(
"Row alignment: building locus-overlap canonical map over {} names \
across {} file(s)",
names.len(),
opened.len()
);
let canon = crate::aux::feature_names::build_locus_overlap_canonicalizer(&names);
data_vec = data_vec
.with_row_canonicalizer(move |name| canon(name))
.expect("with_row_canonicalizer on empty SparseIoVec");
}
kind => {
if let Some(canon) = kind.clone().into_canonicalizer() {
debug!(
"Row alignment: applying {:?} canonicalizer across {} file(s)",
kind,
opened.len()
);
data_vec = data_vec
.with_row_canonicalizer(move |name| canon(name))
.expect("with_row_canonicalizer on empty SparseIoVec");
}
}
}
info!(
"Row alignment: {:?} · {:?} canon{} · {} file(s)",
args.row_alignment,
resolved_kind,
if kind_was_auto { " (auto)" } else { "" },
opened.len(),
);
for (file_idx, (data_file, data)) in opened.into_iter().enumerate() {
let data_name = attach_data_name.then(|| basename(&data_file)).transpose()?;
let barcode_suffix: Option<&str> = args
.per_file_barcode_suffix
.as_ref()
.and_then(|v| v[file_idx].as_deref());
data_vec.push_with_barcode_suffix(Arc::from(data), data_name, barcode_suffix)?;
}
let intersection_size = data_vec.num_rows();
for j in 0..data_vec.len() {
let backend_rows = data_vec[j].num_rows().unwrap_or(0);
if backend_rows != intersection_size {
info!(
"Backend {} has {} rows; using {} shared rows for fitting",
j, backend_rows, intersection_size
);
}
}
if args.column_alignment == ColumnAlignment::Disjoint && data_vec.len() >= 2 {
maybe_warn_multimodal_pattern(&data_vec);
}
let n_cells = data_vec.num_columns();
let mut batch_membership: Vec<Box<str>> = match args.column_alignment {
ColumnAlignment::Disjoint => resolve_batch_disjoint(
&args.data_files,
&data_vec,
args.batch_files.as_deref(),
attach_data_name,
)?,
ColumnAlignment::Union => {
resolve_batch_union(&data_vec, args.batch_files.as_deref(), n_cells)?
}
};
if batch_membership.len() != data_vec.num_columns() {
return Err(anyhow::anyhow!(
"# batch membership {} != # of columns {}",
batch_membership.len(),
data_vec.num_columns()
));
}
if !args.keep_empty_barcodes {
if let Some(flags) = args.qc_exempt_files.as_ref() {
anyhow::ensure!(
flags.len() == args.data_files.len(),
"qc_exempt_files has {} entries for {} data files",
flags.len(),
args.data_files.len(),
);
}
if let Some(keep) = empty_barcode_keep(&data_vec, args.qc_exempt_files.as_deref()) {
data_vec.mask_columns(&keep)?;
batch_membership = crate::qc_lib::filter_by_keep(&batch_membership, &keep);
}
}
let output_keep_idx = if let Some(cfg) = args.qc.as_ref() {
let mut exempt: Option<Vec<bool>> = None;
if let Some(flags) = args.qc_exempt_files.as_ref() {
anyhow::ensure!(
flags.len() == args.data_files.len(),
"qc_exempt_files has {} entries for {} data files",
flags.len(),
args.data_files.len(),
);
if flags.iter().any(|&f| f) {
exempt = Some(
(0..data_vec.num_columns())
.map(|c| data_vec.column_source(c).is_some_and(|b| flags[b]))
.collect(),
);
}
}
let report = crate::qc_lib::compute_qc_exempting(
&data_vec,
cfg,
args.qc_block_size,
exempt.as_deref(),
)?;
if let Some(path) = args.qc_report_out.as_deref() {
crate::qc_lib::write_qc_report(path, &data_vec.column_names()?, &report)?;
}
let n_near_empty = report.near_empty.iter().filter(|&&e| e).count();
info!(
"QC: dropped {}/{} cells from training, {} near-empty masked at output, {}/{} features dropped",
report.n_cells_dropped,
report.train_keep.len(),
n_near_empty,
report.n_features_dropped,
report.feature_keep.len(),
);
if report.n_features_dropped > 0 {
data_vec.mask_rows(&report.feature_keep)?;
}
let keep_idx = report.output_keep_idx();
if report.n_cells_dropped > 0 {
data_vec.mask_columns(&report.train_keep)?;
batch_membership = crate::qc_lib::filter_by_keep(&batch_membership, &report.train_keep);
}
Some(keep_idx)
} else {
None
};
Ok(SparseDataWithBatch {
data: data_vec,
batch: batch_membership,
output_keep_idx,
})
}
fn empty_barcode_keep(data_vec: &SparseIoVec, exempt: Option<&[bool]>) -> Option<Vec<bool>> {
let mut missing_indptr: Vec<usize> = Vec::new();
let cutoffs: Vec<Option<u64>> = (0..data_vec.len())
.map(|b| {
if exempt.is_some_and(|e| e[b]) {
return None;
}
let backend = &data_vec[b];
let ncol = backend.num_columns().unwrap_or(0);
let nnz: Option<Vec<f32>> = (0..ncol)
.map(|c| backend.column_nnz(c).map(|x| x as f32))
.collect();
let Some(nnz) = nnz else {
missing_indptr.push(b);
return None;
};
crate::qc::suggest_nnz_cutoff(&nnz).map(|c| c as u64)
})
.collect();
if !missing_indptr.is_empty() {
warn!(
"Empty-barcode gate: file index(es) {} have no resident column indptr; \
skipping cell call for those backends",
missing_indptr
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", "),
);
}
if cutoffs.iter().all(Option::is_none) {
return None;
}
let keep: Vec<bool> = (0..data_vec.num_columns())
.map(|c| {
data_vec.column_locations(c).iter().any(|loc| {
let b = loc.backend as usize;
cutoffs[b].is_none_or(|cut| {
data_vec[b]
.column_nnz(loc.local_col as usize)
.is_none_or(|x| x >= cut)
})
})
})
.collect();
let n_drop = keep.iter().filter(|&&k| !k).count();
info!(
"Empty-barcode gate: {} / {} columns called empty (per-file nnz cutoffs: {})",
n_drop,
keep.len(),
cutoffs
.iter()
.map(|c| c.map_or("none".to_string(), |x| x.to_string()))
.collect::<Vec<_>>()
.join(", "),
);
(n_drop > 0 && n_drop < keep.len()).then_some(keep)
}
fn maybe_warn_multimodal_pattern(data_vec: &SparseIoVec) {
let n_backends = data_vec.len();
if n_backends < 2 {
return;
}
let intersection = data_vec.num_rows_in_at_least(n_backends);
let min_backend_rows = (0..n_backends)
.map(|j| data_vec[j].num_rows().unwrap_or(0))
.min()
.unwrap_or(0);
if min_backend_rows == 0 {
return;
}
let disjointness = 1.0_f64 - (intersection as f64) / (min_backend_rows as f64);
if disjointness < MULTIMODAL_HINT_DISJOINTNESS_FRACTION {
return;
}
let mut shared: Option<FxHashSet<Box<str>>> = None;
for j in 0..n_backends {
let names = match data_vec[j].column_names() {
Ok(n) => n,
Err(_) => return, };
let set: FxHashSet<Box<str>> = names.into_iter().collect();
match shared.as_mut() {
None => shared = Some(set),
Some(prev) => {
prev.retain(|k| set.contains(k));
if prev.is_empty() {
return;
}
}
}
}
let shared_count = shared.map(|s| s.len()).unwrap_or(0);
if shared_count == 0 {
return;
}
warn!(
"Inputs look multi-modal-shaped (feature-axis disjointness {:.0}% across {} \
backends) and {} barcode(s) overlap across files. To glue cells across \
modalities, pass `--multiome` (or the equivalent ColumnAlignment::Union). \
Continuing with default Disjoint stacking — cells with shared barcodes \
will be treated as distinct.",
disjointness * 100.0,
n_backends,
shared_count
);
}
fn resolve_batch_disjoint(
data_files: &[Box<str>],
data_vec: &SparseIoVec,
batch_files: Option<&[Box<str>]>,
attach_data_name: bool,
) -> anyhow::Result<Vec<Box<str>>> {
let mut batch_membership: Vec<Box<str>> = Vec::with_capacity(data_vec.num_columns());
if let Some(batch_files) = batch_files {
if batch_files.len() != data_files.len() {
return Err(anyhow::anyhow!("# batch files != # of data files"));
}
for batch_file in batch_files.iter() {
info!("Reading batch file: {}", batch_file);
for s in read_lines(batch_file)? {
batch_membership.push(s.to_string().into_boxed_str());
}
}
} else {
let column_counts = data_vec.num_columns_by_data()?;
let column_names = data_vec.column_names()?;
let mut col_start = 0usize;
for (file_idx, &ncols) in column_counts.iter().enumerate() {
let data_file = data_files[file_idx].clone();
let (_dir, file_base, _ext) = common_io::dir_base_ext(&data_file)?;
let col_end = col_start + ncols;
let file_columns = &column_names[col_start..col_end];
let appended_suffix =
attach_data_name.then(|| format!("@{}", file_base).into_boxed_str());
let (tags, used_embedded) = infer_batch_from_columns(
file_columns,
file_base.as_ref(),
appended_suffix.as_deref(),
);
if used_embedded {
info!(
"File {}: using embedded batch from column names (file '{}')",
file_idx, file_base
);
} else {
info!(
"File {}: using file name '{}' as batch",
file_idx, file_base
);
}
batch_membership.extend(tags);
col_start = col_end;
}
}
Ok(batch_membership)
}
fn resolve_batch_union(
data_vec: &SparseIoVec,
batch_files: Option<&[Box<str>]>,
n_cells: usize,
) -> anyhow::Result<Vec<Box<str>>> {
if let Some(batch_files) = batch_files {
if batch_files.len() != 1 {
return Err(anyhow::anyhow!(
"Under ColumnAlignment::Union, --batch-files must have exactly one \
file listing one label per unified cell (got {} files for {} \
unified cells). A cell shared across modalities cannot carry two \
batch labels.",
batch_files.len(),
n_cells
));
}
info!("Reading unified batch file: {}", batch_files[0]);
let labels: Vec<Box<str>> = read_lines(&batch_files[0])?;
if labels.len() != n_cells {
return Err(anyhow::anyhow!(
"Unified batch file {} has {} lines but data has {} unified cells",
batch_files[0],
labels.len(),
n_cells
));
}
return Ok(labels);
}
let unified_names = data_vec.column_names()?;
let (per_cell_tags, used_embedded) = infer_batch_from_columns(&unified_names, "", None);
if used_embedded {
info!(
"Union mode: per-cell @batch tag taken from unified barcodes ({} cells)",
n_cells
);
return Ok(per_cell_tags);
}
info!(
"No --batch-files and no embedded @batch tags — falling back to single \
batch 'all' (Union mode: per-file batch fallback is ambiguous)."
);
Ok(vec!["all".to_string().into_boxed_str(); n_cells])
}
fn infer_batch_from_columns(
file_columns: &[Box<str>],
file_base: &str,
appended_suffix: Option<&str>,
) -> (Vec<Box<str>>, bool) {
fn raw_of<'a>(name: &'a str, suffix: Option<&str>) -> &'a str {
match suffix {
Some(sfx) => name.strip_suffix(sfx).unwrap_or(name),
None => name,
}
}
let has_embedded_batch = file_columns
.first()
.is_some_and(|name| raw_of(name.as_ref(), appended_suffix).contains('@'));
if has_embedded_batch {
let tags = file_columns
.iter()
.map(|col_name| {
let raw = raw_of(col_name.as_ref(), appended_suffix);
let embedded = raw.rsplit('@').next().unwrap_or(raw);
embedded.to_string().into_boxed_str()
})
.collect();
(tags, true)
} else {
let fallback: Box<str> = file_base.to_string().into_boxed_str();
(vec![fallback; file_columns.len()], false)
}
}
#[cfg(test)]
#[path = "data_loading_tests.rs"]
mod data_loading_tests;
#[cfg(test)]
mod tests {
use super::*;
fn cols(v: &[&str]) -> Vec<Box<str>> {
v.iter()
.map(|s| (*s).to_string().into_boxed_str())
.collect()
}
#[test]
fn embedded_donor_survives_push_suffix() {
let names = cols(&[
"ACGT-1@donorA@mix",
"ACGT-2@donorB@mix",
"ACGT-3@donorA@mix",
"ACGT-4@donorB@mix",
]);
let (tags, used_embedded) = infer_batch_from_columns(&names, "mix", Some("@mix"));
assert!(used_embedded);
assert_eq!(
tags.iter().map(|b| b.as_ref()).collect::<Vec<_>>(),
vec!["donorA", "donorB", "donorA", "donorB"]
);
}
#[test]
fn no_embedded_batch_falls_back_to_file_base() {
let names = cols(&["AAAA@s1", "CCCC@s1"]);
let (tags, used_embedded) = infer_batch_from_columns(&names, "s1", Some("@s1"));
assert!(!used_embedded);
assert_eq!(
tags.iter().map(|b| b.as_ref()).collect::<Vec<_>>(),
vec!["s1", "s1"]
);
}
#[test]
fn single_file_embedded_batch() {
let names = cols(&["ACGT-1@donorA", "ACGT-2@donorB"]);
let (tags, used_embedded) = infer_batch_from_columns(&names, "only", None);
assert!(used_embedded);
assert_eq!(
tags.iter().map(|b| b.as_ref()).collect::<Vec<_>>(),
vec!["donorA", "donorB"]
);
}
#[test]
fn single_file_no_embedded_batch() {
let names = cols(&["AAAA", "CCCC"]);
let (tags, used_embedded) = infer_batch_from_columns(&names, "only", None);
assert!(!used_embedded);
assert_eq!(
tags.iter().map(|b| b.as_ref()).collect::<Vec<_>>(),
vec!["only", "only"]
);
}
#[test]
fn empty_file_columns() {
let names: Vec<Box<str>> = vec![];
let (tags, used_embedded) = infer_batch_from_columns(&names, "x", Some("@x"));
assert!(!used_embedded);
assert!(tags.is_empty());
}
}