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 || crate::sparse_io::auto_preload_enabled() {
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 let column_counts = data_vec.num_columns_by_data()?;
564 for (file_idx, batch_file) in batch_files.iter().enumerate() {
565 info!("Reading batch file: {}", batch_file);
566 let labels = read_lines(batch_file)?;
567 if labels.len() != column_counts[file_idx] {
568 return Err(anyhow::anyhow!(
569 "batch file {} has {} lines but data file {} has {} columns",
570 batch_file,
571 labels.len(),
572 data_files[file_idx],
573 column_counts[file_idx]
574 ));
575 }
576 batch_membership.extend(labels);
577 }
578 } else {
579 let column_counts = data_vec.num_columns_by_data()?;
580 let column_names = data_vec.column_names()?;
581 let mut col_start = 0usize;
582
583 for (file_idx, &ncols) in column_counts.iter().enumerate() {
584 let data_file = data_files[file_idx].clone();
585 let (_dir, file_base, _ext) = common_io::dir_base_ext(&data_file)?;
586 let col_end = col_start + ncols;
587 let file_columns = &column_names[col_start..col_end];
588
589 let appended_suffix =
590 attach_data_name.then(|| format!("@{}", file_base).into_boxed_str());
591 let (tags, used_embedded) = infer_batch_from_columns(
592 file_columns,
593 file_base.as_ref(),
594 appended_suffix.as_deref(),
595 );
596 if used_embedded {
597 info!(
598 "File {}: using embedded batch from column names (file '{}')",
599 file_idx, file_base
600 );
601 } else {
602 info!(
603 "File {}: using file name '{}' as batch",
604 file_idx, file_base
605 );
606 }
607 batch_membership.extend(tags);
608 col_start = col_end;
609 }
610 }
611 Ok(batch_membership)
612}
613
614fn resolve_batch_union(
628 data_vec: &SparseIoVec,
629 batch_files: Option<&[Box<str>]>,
630 n_cells: usize,
631) -> anyhow::Result<Vec<Box<str>>> {
632 if let Some(batch_files) = batch_files {
633 if batch_files.len() != 1 {
634 return Err(anyhow::anyhow!(
635 "Under ColumnAlignment::Union, --batch-files must have exactly one \
636 file listing one label per unified cell (got {} files for {} \
637 unified cells). A cell shared across modalities cannot carry two \
638 batch labels.",
639 batch_files.len(),
640 n_cells
641 ));
642 }
643 info!("Reading unified batch file: {}", batch_files[0]);
644 let labels: Vec<Box<str>> = read_lines(&batch_files[0])?;
645 if labels.len() != n_cells {
646 return Err(anyhow::anyhow!(
647 "Unified batch file {} has {} lines but data has {} unified cells",
648 batch_files[0],
649 labels.len(),
650 n_cells
651 ));
652 }
653 return Ok(labels);
654 }
655
656 let unified_names = data_vec.column_names()?;
664 let (per_cell_tags, used_embedded) = infer_batch_from_columns(&unified_names, "all", None);
667 if used_embedded {
668 info!(
669 "Union mode: per-cell @batch tag taken from unified barcodes ({} cells)",
670 n_cells
671 );
672 } else {
673 info!(
674 "No --batch-files and no embedded @batch tags — falling back to single \
675 batch 'all' (Union mode: per-file batch fallback is ambiguous)."
676 );
677 }
678 Ok(per_cell_tags)
679}
680
681fn infer_batch_from_columns(
696 file_columns: &[Box<str>],
697 file_base: &str,
698 appended_suffix: Option<&str>,
699) -> (Vec<Box<str>>, bool) {
700 fn raw_of<'a>(name: &'a str, suffix: Option<&str>) -> &'a str {
701 match suffix {
702 Some(sfx) => name.strip_suffix(sfx).unwrap_or(name),
703 None => name,
704 }
705 }
706
707 let mut used_embedded = false;
708 let tags = file_columns
709 .iter()
710 .map(|col_name| {
711 let raw = raw_of(col_name.as_ref(), appended_suffix);
712 match raw.rsplit_once('@') {
713 Some((_, embedded)) => {
714 used_embedded = true;
715 embedded.into()
716 }
717 None => file_base.into(),
718 }
719 })
720 .collect();
721 (tags, used_embedded)
722}
723
724#[cfg(test)]
725#[path = "data_loading_tests.rs"]
726mod data_loading_tests;
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731
732 fn cols(v: &[&str]) -> Vec<Box<str>> {
733 v.iter()
734 .map(|s| (*s).to_string().into_boxed_str())
735 .collect()
736 }
737
738 #[test]
739 fn embedded_donor_survives_push_suffix() {
740 let names = cols(&[
744 "ACGT-1@donorA@mix",
745 "ACGT-2@donorB@mix",
746 "ACGT-3@donorA@mix",
747 "ACGT-4@donorB@mix",
748 ]);
749 let (tags, used_embedded) = infer_batch_from_columns(&names, "mix", Some("@mix"));
750 assert!(used_embedded);
751 assert_eq!(
752 tags.iter().map(|b| b.as_ref()).collect::<Vec<_>>(),
753 vec!["donorA", "donorB", "donorA", "donorB"]
754 );
755 }
756
757 #[test]
758 fn no_embedded_batch_falls_back_to_file_base() {
759 let names = cols(&["AAAA@s1", "CCCC@s1"]);
761 let (tags, used_embedded) = infer_batch_from_columns(&names, "s1", Some("@s1"));
762 assert!(!used_embedded);
763 assert_eq!(
764 tags.iter().map(|b| b.as_ref()).collect::<Vec<_>>(),
765 vec!["s1", "s1"]
766 );
767 }
768
769 #[test]
770 fn single_file_embedded_batch() {
771 let names = cols(&["ACGT-1@donorA", "ACGT-2@donorB"]);
773 let (tags, used_embedded) = infer_batch_from_columns(&names, "only", None);
774 assert!(used_embedded);
775 assert_eq!(
776 tags.iter().map(|b| b.as_ref()).collect::<Vec<_>>(),
777 vec!["donorA", "donorB"]
778 );
779 }
780
781 #[test]
782 fn single_file_no_embedded_batch() {
783 let names = cols(&["AAAA", "CCCC"]);
784 let (tags, used_embedded) = infer_batch_from_columns(&names, "only", None);
785 assert!(!used_embedded);
786 assert_eq!(
787 tags.iter().map(|b| b.as_ref()).collect::<Vec<_>>(),
788 vec!["only", "only"]
789 );
790 }
791
792 #[test]
793 fn each_name_is_read_for_its_own_tag() {
794 for (names, want) in [
797 (
798 cols(&["AAAA@donorA", "CCCC", "GGGG@donorB"]),
799 ["donorA", "all", "donorB"],
800 ),
801 (
802 cols(&["CCCC", "AAAA@donorA", "GGGG@donorB"]),
803 ["all", "donorA", "donorB"],
804 ),
805 ] {
806 let (tags, used_embedded) = infer_batch_from_columns(&names, "all", None);
807 assert!(used_embedded);
808 assert_eq!(tags.iter().map(|b| b.as_ref()).collect::<Vec<_>>(), want);
809 }
810 }
811
812 #[test]
813 fn empty_file_columns() {
814 let names: Vec<Box<str>> = vec![];
815 let (tags, used_embedded) = infer_batch_from_columns(&names, "x", Some("@x"));
816 assert!(!used_embedded);
817 assert!(tags.is_empty());
818 }
819}