1use std::sync::Arc;
2
3use log::{debug, info, warn};
4
5use crate::convert::try_open_or_convert;
6use crate::sparse_io_vector::SparseIoVec;
7use legume_numeric::matrix::common_io::{self, basename, read_lines};
8use rustc_hash::FxHashSet;
9
10use crate::aux::feature_names::FeatureNameKind;
11use crate::sparse_io_vector::{ColumnAlignment, RowAlignment};
12
13const MULTIMODAL_HINT_DISJOINTNESS_FRACTION: f64 = 0.5;
20
21#[derive(Default)]
23pub struct ReadSharedRowsArgs {
24 pub data_files: Vec<Box<str>>,
25 pub batch_files: Option<Vec<Box<str>>>,
26 pub preload: bool,
27 pub feature_kind: Option<FeatureNameKind>,
32 pub row_alignment: RowAlignment,
40 pub column_alignment: ColumnAlignment,
49 pub qc: Option<crate::qc_lib::QcConfig>,
56 pub keep_empty_barcodes: bool,
67 pub qc_exempt_files: Option<Vec<bool>>,
76 pub qc_block_size: Option<usize>,
78 pub qc_report_out: Option<Box<str>>,
80 pub per_file_feature_suffix: Option<Vec<Box<str>>>,
87 pub per_file_barcode_suffix: Option<Vec<Option<Box<str>>>>,
97}
98
99pub struct SparseDataWithBatch {
101 pub data: SparseIoVec,
102 pub batch: Vec<Box<str>>,
103 pub output_keep_idx: Option<Vec<usize>>,
107}
108
109pub fn read_data_on_shared_rows(args: ReadSharedRowsArgs) -> anyhow::Result<SparseDataWithBatch> {
116 let attach_data_name = args.data_files.len() > 1;
118
119 type OpenedBackend = Box<dyn crate::sparse_io::SparseIo<IndexIter = Vec<usize>>>;
122 let mut opened: Vec<(Box<str>, OpenedBackend)> = Vec::with_capacity(args.data_files.len());
123 for data_file in args.data_files.iter() {
124 info!("Importing data file: {}", data_file);
125 let mut data = try_open_or_convert(data_file)?;
126 if args.preload {
127 data.preload_columns()?;
128 }
129 opened.push((data_file.clone(), data));
130 }
131
132 let attach_data_name = attach_data_name && args.column_alignment == ColumnAlignment::Disjoint;
139
140 let mut data_vec = SparseIoVec::new()
141 .with_row_alignment(args.row_alignment)
142 .expect("with_row_alignment on empty SparseIoVec")
143 .with_column_alignment(args.column_alignment)
144 .expect("with_column_alignment on empty SparseIoVec");
145
146 if let Some(suffix) = args.per_file_feature_suffix.clone() {
150 anyhow::ensure!(
151 suffix.len() == args.data_files.len(),
152 "per_file_feature_suffix has {} entries but {} data files were given",
153 suffix.len(),
154 args.data_files.len(),
155 );
156 data_vec = data_vec
157 .with_per_backend_row_suffix(suffix)
158 .expect("with_per_backend_row_suffix on empty SparseIoVec");
159 }
160
161 if let Some(sfx) = args.per_file_barcode_suffix.as_ref() {
164 anyhow::ensure!(
165 sfx.len() == args.data_files.len(),
166 "per_file_barcode_suffix has {} entries but {} data files were given",
167 sfx.len(),
168 args.data_files.len(),
169 );
170 }
171
172 use crate::aux::feature_names::FeatureNameKind;
173
174 let needs_names = args.feature_kind.is_none()
177 || args
178 .feature_kind
179 .as_ref()
180 .is_some_and(|k| k.needs_global_pass());
181 let mut file_ends: Vec<usize> = Vec::with_capacity(opened.len());
186 let all_names: Option<Vec<Box<str>>> = if needs_names {
187 let mut acc: Vec<Box<str>> = Vec::new();
188 for (_, d) in opened.iter() {
189 acc.extend(d.row_names()?);
190 file_ends.push(acc.len());
191 }
192 Some(acc)
193 } else {
194 None
195 };
196
197 let kind_was_auto = args.feature_kind.is_none();
198 let resolved_kind: FeatureNameKind = match args.feature_kind.clone() {
199 Some(k) => k,
200 None => {
201 let names = all_names.as_ref().expect("peeked when auto");
208 let per_file: Vec<FeatureNameKind> = std::iter::once(0)
212 .chain(file_ends.iter().copied())
213 .zip(file_ends.iter().copied())
214 .map(|(start, end)| FeatureNameKind::auto_detect(&names[start..end]))
215 .collect();
216 let k = FeatureNameKind::reconcile(&per_file);
217 debug!(
218 "Row alignment: auto-detected feature name kind → {:?} (per file: {:?}; {} rows)",
219 k,
220 per_file,
221 names.len()
222 );
223 k
224 }
225 };
226
227 match &resolved_kind {
232 FeatureNameKind::Mixed => {
233 let names = all_names.as_ref().expect("peeked for Mixed").clone();
234 debug!(
235 "Row alignment: building MIXED-kind canonical map over {} names \
236 across {} file(s)",
237 names.len(),
238 opened.len()
239 );
240 let canon = crate::aux::feature_names::build_mixed_kind_canonicalizer(&names);
241 data_vec = data_vec
242 .with_row_canonicalizer(move |name| canon(name))
243 .expect("with_row_canonicalizer on empty SparseIoVec");
244 }
245 FeatureNameKind::Locus {
246 merge_overlapping: true,
247 } => {
248 let names = all_names
249 .as_ref()
250 .expect("peeked for Locus merge_overlapping")
251 .clone();
252 debug!(
253 "Row alignment: building locus-overlap canonical map over {} names \
254 across {} file(s)",
255 names.len(),
256 opened.len()
257 );
258 let canon = crate::aux::feature_names::build_locus_overlap_canonicalizer(&names);
259 data_vec = data_vec
260 .with_row_canonicalizer(move |name| canon(name))
261 .expect("with_row_canonicalizer on empty SparseIoVec");
262 }
263 kind => {
264 if let Some(canon) = kind.clone().into_canonicalizer() {
265 debug!(
266 "Row alignment: applying {:?} canonicalizer across {} file(s)",
267 kind,
268 opened.len()
269 );
270 data_vec = data_vec
273 .with_row_canonicalizer(move |name| canon(name))
274 .expect("with_row_canonicalizer on empty SparseIoVec");
275 }
276 }
277 }
278 info!(
279 "Row alignment: {:?} · {:?} canon{} · {} file(s)",
280 args.row_alignment,
281 resolved_kind,
282 if kind_was_auto { " (auto)" } else { "" },
283 opened.len(),
284 );
285 for (file_idx, (data_file, data)) in opened.into_iter().enumerate() {
286 let data_name = attach_data_name.then(|| basename(&data_file)).transpose()?;
287 let barcode_suffix: Option<&str> = args
289 .per_file_barcode_suffix
290 .as_ref()
291 .and_then(|v| v[file_idx].as_deref());
292 data_vec.push_with_barcode_suffix(Arc::from(data), data_name, barcode_suffix)?;
293 }
294
295 let intersection_size = data_vec.num_rows();
299 for j in 0..data_vec.len() {
300 let backend_rows = data_vec[j].num_rows().unwrap_or(0);
301 if backend_rows != intersection_size {
302 info!(
303 "Backend {} has {} rows; using {} shared rows for fitting",
304 j, backend_rows, intersection_size
305 );
306 }
307 }
308
309 if args.column_alignment == ColumnAlignment::Disjoint && data_vec.len() >= 2 {
315 maybe_warn_multimodal_pattern(&data_vec);
316 }
317
318 let n_cells = data_vec.num_columns();
320 let mut batch_membership: Vec<Box<str>> = match args.column_alignment {
321 ColumnAlignment::Disjoint => resolve_batch_disjoint(
322 &args.data_files,
323 &data_vec,
324 args.batch_files.as_deref(),
325 attach_data_name,
326 )?,
327 ColumnAlignment::Union => {
328 resolve_batch_union(&data_vec, args.batch_files.as_deref(), n_cells)?
329 }
330 };
331
332 if batch_membership.len() != data_vec.num_columns() {
333 return Err(anyhow::anyhow!(
334 "# batch membership {} != # of columns {}",
335 batch_membership.len(),
336 data_vec.num_columns()
337 ));
338 }
339
340 if !args.keep_empty_barcodes {
345 if let Some(flags) = args.qc_exempt_files.as_ref() {
346 anyhow::ensure!(
347 flags.len() == args.data_files.len(),
348 "qc_exempt_files has {} entries for {} data files",
349 flags.len(),
350 args.data_files.len(),
351 );
352 }
353 if let Some(keep) = empty_barcode_keep(&data_vec, args.qc_exempt_files.as_deref()) {
354 data_vec.mask_columns(&keep)?;
355 batch_membership = crate::qc_lib::filter_by_keep(&batch_membership, &keep);
356 }
357 }
358
359 let output_keep_idx = if let Some(cfg) = args.qc.as_ref() {
363 let mut exempt: Option<Vec<bool>> = None;
367 if let Some(flags) = args.qc_exempt_files.as_ref() {
368 anyhow::ensure!(
369 flags.len() == args.data_files.len(),
370 "qc_exempt_files has {} entries for {} data files",
371 flags.len(),
372 args.data_files.len(),
373 );
374 if flags.iter().any(|&f| f) {
375 exempt = Some(
376 (0..data_vec.num_columns())
377 .map(|c| data_vec.column_source(c).is_some_and(|b| flags[b]))
378 .collect(),
379 );
380 }
381 }
382 let report = crate::qc_lib::compute_qc_exempting(
383 &data_vec,
384 cfg,
385 args.qc_block_size,
386 exempt.as_deref(),
387 )?;
388 if let Some(path) = args.qc_report_out.as_deref() {
389 crate::qc_lib::write_qc_report(path, &data_vec.column_names()?, &report)?;
390 }
391 let n_near_empty = report.near_empty.iter().filter(|&&e| e).count();
392 info!(
393 "QC: dropped {}/{} cells from training, {} near-empty masked at output, {}/{} features dropped",
394 report.n_cells_dropped,
395 report.train_keep.len(),
396 n_near_empty,
397 report.n_features_dropped,
398 report.feature_keep.len(),
399 );
400 if report.n_features_dropped > 0 {
402 data_vec.mask_rows(&report.feature_keep)?;
403 }
404 let keep_idx = report.output_keep_idx();
407 if report.n_cells_dropped > 0 {
408 data_vec.mask_columns(&report.train_keep)?;
409 batch_membership = crate::qc_lib::filter_by_keep(&batch_membership, &report.train_keep);
410 }
411 Some(keep_idx)
412 } else {
413 None
414 };
415
416 Ok(SparseDataWithBatch {
417 data: data_vec,
418 batch: batch_membership,
419 output_keep_idx,
420 })
421}
422
423fn empty_barcode_keep(data_vec: &SparseIoVec, exempt: Option<&[bool]>) -> Option<Vec<bool>> {
430 let mut missing_indptr: Vec<usize> = Vec::new();
431 let cutoffs: Vec<Option<u64>> = (0..data_vec.len())
432 .map(|b| {
433 if exempt.is_some_and(|e| e[b]) {
434 return None;
435 }
436 let backend = &data_vec[b];
437 let ncol = backend.num_columns().unwrap_or(0);
438 let nnz: Option<Vec<f32>> = (0..ncol)
439 .map(|c| backend.column_nnz(c).map(|x| x as f32))
440 .collect();
441 let Some(nnz) = nnz else {
442 missing_indptr.push(b);
443 return None;
444 };
445 crate::qc::suggest_nnz_cutoff(&nnz).map(|c| c as u64)
446 })
447 .collect();
448 if !missing_indptr.is_empty() {
449 warn!(
450 "Empty-barcode gate: file index(es) {} have no resident column indptr; \
451 skipping cell call for those backends",
452 missing_indptr
453 .iter()
454 .map(ToString::to_string)
455 .collect::<Vec<_>>()
456 .join(", "),
457 );
458 }
459 if cutoffs.iter().all(Option::is_none) {
460 return None;
461 }
462
463 let keep: Vec<bool> = (0..data_vec.num_columns())
464 .map(|c| {
465 data_vec.column_locations(c).iter().any(|loc| {
466 let b = loc.backend as usize;
467 cutoffs[b].is_none_or(|cut| {
468 data_vec[b]
469 .column_nnz(loc.local_col as usize)
470 .is_none_or(|x| x >= cut)
471 })
472 })
473 })
474 .collect();
475 let n_drop = keep.iter().filter(|&&k| !k).count();
476 info!(
477 "Empty-barcode gate: {} / {} columns called empty (per-file nnz cutoffs: {})",
478 n_drop,
479 keep.len(),
480 cutoffs
481 .iter()
482 .map(|c| c.map_or("none".to_string(), |x| x.to_string()))
483 .collect::<Vec<_>>()
484 .join(", "),
485 );
486 (n_drop > 0 && n_drop < keep.len()).then_some(keep)
488}
489
490fn maybe_warn_multimodal_pattern(data_vec: &SparseIoVec) {
494 let n_backends = data_vec.len();
495 if n_backends < 2 {
496 return;
497 }
498 let intersection = data_vec.num_rows_in_at_least(n_backends);
499 let min_backend_rows = (0..n_backends)
500 .map(|j| data_vec[j].num_rows().unwrap_or(0))
501 .min()
502 .unwrap_or(0);
503 if min_backend_rows == 0 {
504 return;
505 }
506 let disjointness = 1.0_f64 - (intersection as f64) / (min_backend_rows as f64);
507 if disjointness < MULTIMODAL_HINT_DISJOINTNESS_FRACTION {
508 return;
509 }
510
511 let mut shared: Option<FxHashSet<Box<str>>> = None;
515 for j in 0..n_backends {
516 let names = match data_vec[j].column_names() {
517 Ok(n) => n,
518 Err(_) => return, };
520 let set: FxHashSet<Box<str>> = names.into_iter().collect();
521 match shared.as_mut() {
522 None => shared = Some(set),
523 Some(prev) => {
524 prev.retain(|k| set.contains(k));
525 if prev.is_empty() {
526 return;
527 }
528 }
529 }
530 }
531 let shared_count = shared.map(|s| s.len()).unwrap_or(0);
532 if shared_count == 0 {
533 return;
534 }
535
536 warn!(
537 "Inputs look multi-modal-shaped (feature-axis disjointness {:.0}% across {} \
538 backends) and {} barcode(s) overlap across files. To glue cells across \
539 modalities, pass `--multiome` (or the equivalent ColumnAlignment::Union). \
540 Continuing with default Disjoint stacking — cells with shared barcodes \
541 will be treated as distinct.",
542 disjointness * 100.0,
543 n_backends,
544 shared_count
545 );
546}
547
548fn resolve_batch_disjoint(
550 data_files: &[Box<str>],
551 data_vec: &SparseIoVec,
552 batch_files: Option<&[Box<str>]>,
553 attach_data_name: bool,
554) -> anyhow::Result<Vec<Box<str>>> {
555 let mut batch_membership: Vec<Box<str>> = Vec::with_capacity(data_vec.num_columns());
556
557 if let Some(batch_files) = batch_files {
558 if batch_files.len() != data_files.len() {
559 return Err(anyhow::anyhow!("# batch files != # of data files"));
560 }
561 for batch_file in batch_files.iter() {
562 info!("Reading batch file: {}", batch_file);
563 for s in read_lines(batch_file)? {
564 batch_membership.push(s.to_string().into_boxed_str());
565 }
566 }
567 } else {
568 let column_counts = data_vec.num_columns_by_data()?;
569 let column_names = data_vec.column_names()?;
570 let mut col_start = 0usize;
571
572 for (file_idx, &ncols) in column_counts.iter().enumerate() {
573 let data_file = data_files[file_idx].clone();
574 let (_dir, file_base, _ext) = common_io::dir_base_ext(&data_file)?;
575 let col_end = col_start + ncols;
576 let file_columns = &column_names[col_start..col_end];
577
578 let appended_suffix =
579 attach_data_name.then(|| format!("@{}", file_base).into_boxed_str());
580 let (tags, used_embedded) = infer_batch_from_columns(
581 file_columns,
582 file_base.as_ref(),
583 appended_suffix.as_deref(),
584 );
585 if used_embedded {
586 info!(
587 "File {}: using embedded batch from column names (file '{}')",
588 file_idx, file_base
589 );
590 } else {
591 info!(
592 "File {}: using file name '{}' as batch",
593 file_idx, file_base
594 );
595 }
596 batch_membership.extend(tags);
597 col_start = col_end;
598 }
599 }
600 Ok(batch_membership)
601}
602
603fn resolve_batch_union(
617 data_vec: &SparseIoVec,
618 batch_files: Option<&[Box<str>]>,
619 n_cells: usize,
620) -> anyhow::Result<Vec<Box<str>>> {
621 if let Some(batch_files) = batch_files {
622 if batch_files.len() != 1 {
623 return Err(anyhow::anyhow!(
624 "Under ColumnAlignment::Union, --batch-files must have exactly one \
625 file listing one label per unified cell (got {} files for {} \
626 unified cells). A cell shared across modalities cannot carry two \
627 batch labels.",
628 batch_files.len(),
629 n_cells
630 ));
631 }
632 info!("Reading unified batch file: {}", batch_files[0]);
633 let labels: Vec<Box<str>> = read_lines(&batch_files[0])?;
634 if labels.len() != n_cells {
635 return Err(anyhow::anyhow!(
636 "Unified batch file {} has {} lines but data has {} unified cells",
637 batch_files[0],
638 labels.len(),
639 n_cells
640 ));
641 }
642 return Ok(labels);
643 }
644
645 let unified_names = data_vec.column_names()?;
653 let (per_cell_tags, used_embedded) = infer_batch_from_columns(&unified_names, "", None);
654 if used_embedded {
655 info!(
656 "Union mode: per-cell @batch tag taken from unified barcodes ({} cells)",
657 n_cells
658 );
659 return Ok(per_cell_tags);
660 }
661
662 info!(
663 "No --batch-files and no embedded @batch tags — falling back to single \
664 batch 'all' (Union mode: per-file batch fallback is ambiguous)."
665 );
666 Ok(vec!["all".to_string().into_boxed_str(); n_cells])
667}
668
669fn infer_batch_from_columns(
680 file_columns: &[Box<str>],
681 file_base: &str,
682 appended_suffix: Option<&str>,
683) -> (Vec<Box<str>>, bool) {
684 fn raw_of<'a>(name: &'a str, suffix: Option<&str>) -> &'a str {
685 match suffix {
686 Some(sfx) => name.strip_suffix(sfx).unwrap_or(name),
687 None => name,
688 }
689 }
690
691 let has_embedded_batch = file_columns
692 .first()
693 .is_some_and(|name| raw_of(name.as_ref(), appended_suffix).contains('@'));
694
695 if has_embedded_batch {
696 let tags = file_columns
697 .iter()
698 .map(|col_name| {
699 let raw = raw_of(col_name.as_ref(), appended_suffix);
700 let embedded = raw.rsplit('@').next().unwrap_or(raw);
701 embedded.to_string().into_boxed_str()
702 })
703 .collect();
704 (tags, true)
705 } else {
706 let fallback: Box<str> = file_base.to_string().into_boxed_str();
707 (vec![fallback; file_columns.len()], false)
708 }
709}
710
711#[cfg(test)]
712#[path = "data_loading_tests.rs"]
713mod data_loading_tests;
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718
719 fn cols(v: &[&str]) -> Vec<Box<str>> {
720 v.iter()
721 .map(|s| (*s).to_string().into_boxed_str())
722 .collect()
723 }
724
725 #[test]
726 fn embedded_donor_survives_push_suffix() {
727 let names = cols(&[
731 "ACGT-1@donorA@mix",
732 "ACGT-2@donorB@mix",
733 "ACGT-3@donorA@mix",
734 "ACGT-4@donorB@mix",
735 ]);
736 let (tags, used_embedded) = infer_batch_from_columns(&names, "mix", Some("@mix"));
737 assert!(used_embedded);
738 assert_eq!(
739 tags.iter().map(|b| b.as_ref()).collect::<Vec<_>>(),
740 vec!["donorA", "donorB", "donorA", "donorB"]
741 );
742 }
743
744 #[test]
745 fn no_embedded_batch_falls_back_to_file_base() {
746 let names = cols(&["AAAA@s1", "CCCC@s1"]);
748 let (tags, used_embedded) = infer_batch_from_columns(&names, "s1", Some("@s1"));
749 assert!(!used_embedded);
750 assert_eq!(
751 tags.iter().map(|b| b.as_ref()).collect::<Vec<_>>(),
752 vec!["s1", "s1"]
753 );
754 }
755
756 #[test]
757 fn single_file_embedded_batch() {
758 let names = cols(&["ACGT-1@donorA", "ACGT-2@donorB"]);
760 let (tags, used_embedded) = infer_batch_from_columns(&names, "only", None);
761 assert!(used_embedded);
762 assert_eq!(
763 tags.iter().map(|b| b.as_ref()).collect::<Vec<_>>(),
764 vec!["donorA", "donorB"]
765 );
766 }
767
768 #[test]
769 fn single_file_no_embedded_batch() {
770 let names = cols(&["AAAA", "CCCC"]);
771 let (tags, used_embedded) = infer_batch_from_columns(&names, "only", None);
772 assert!(!used_embedded);
773 assert_eq!(
774 tags.iter().map(|b| b.as_ref()).collect::<Vec<_>>(),
775 vec!["only", "only"]
776 );
777 }
778
779 #[test]
780 fn empty_file_columns() {
781 let names: Vec<Box<str>> = vec![];
782 let (tags, used_embedded) = infer_batch_from_columns(&names, "x", Some("@x"));
783 assert!(!used_embedded);
784 assert!(tags.is_empty());
785 }
786}