Skip to main content

data_beans/aux/
data_loading.rs

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
13/// When `RowAlignment::Disjoint` (the historical default) and multiple
14/// files share most of their feature axis, the loader silently does the
15/// horizontal-stack thing. When the axes are *mostly disjoint* AND raw
16/// barcodes overlap, the inputs look multi-modal-shaped and the user
17/// probably meant `--multiome`. This threshold (intersection size /
18/// smallest per-backend row count) decides "mostly disjoint."
19const MULTIMODAL_HINT_DISJOINTNESS_FRACTION: f64 = 0.5;
20
21/// Arguments for loading multiple sparse data files with shared row names.
22#[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    /// Cross-file row-name canonicalization rule. `None` = auto-detect
28    /// via [`FeatureNameKind::auto_detect`] once row names are in hand.
29    /// `Some(kind)` skips detection and uses the caller's choice.
30    /// Default = `None` (auto).
31    pub feature_kind: Option<FeatureNameKind>,
32    /// How to align row names across input files. Default
33    /// [`RowAlignment::Union`] keeps every row from any backend — the
34    /// strictly more permissive option that reduces to single-modality
35    /// semantics when all files share their row set, and supports
36    /// multi-modal load (e.g. paired RNA + ATAC) when they don't.
37    /// Switch to [`RowAlignment::Intersect`] for strict "common rows
38    /// only" behavior.
39    pub row_alignment: RowAlignment,
40    /// How to align column (cell / barcode) names across input files.
41    /// Default [`ColumnAlignment::Disjoint`] concatenates cells with
42    /// `@<basename>` disambiguation, preserving single-modality
43    /// semantics. Switch to [`ColumnAlignment::Union`] for **patchy
44    /// multi-modal (multiome) load**: cells are glued by raw barcode
45    /// across backends, a cell observed in only one modality
46    /// contributes triplets only on that modality's row block, and no
47    /// `@<basename>` suffix is added.
48    pub column_alignment: ColumnAlignment,
49    /// Optional shared cell QC. When `Some`, non-near-empty MAD outlier
50    /// cells are dropped from the working set (via `mask_columns`), the
51    /// returned `batch` Vec is filtered in lockstep, junk features are
52    /// dropped (when `feature_min_cells > 0`), and the near-empty
53    /// output keep-mask is returned in `output_keep_idx`. `None`
54    /// (the default) = no QC, i.e. today's behavior.
55    pub qc: Option<crate::qc_lib::QcConfig>,
56    /// Opt out of the empty-barcode gate. By default each file's columns
57    /// are cell-called on their own nnz distribution
58    /// ([`crate::qc::suggest_nnz_cutoff`]: the trough between the ambient
59    /// and the cell peak) and a column is dropped when it falls below the
60    /// cut in every file that observes it — so an unfiltered barcode axis
61    /// (e.g. ATAC from a fragments file) loses its empty droplets before any
62    /// projection or collapse, while a cell in two modalities survives on
63    /// either. A no-op on already-called data (no trough). Set `true` where
64    /// every input column must come back, e.g. query cells at inference.
65    /// Files flagged in `qc_exempt_files` are never gated.
66    pub keep_empty_barcodes: bool,
67    /// Per-`data_files` entry: `true` exempts that file's columns from QC —
68    /// out of the band statistics AND out of every verdict (see
69    /// `qc_from_metrics`). For inputs whose columns are not cells (a carried
70    /// `pb_reference`, bulk samples): a pseudobulk standing for hundreds of
71    /// cells is a legitimate depth outlier, and letting it into the MAD band
72    /// either gets it dropped or — worse, as the mixture grows — recenters
73    /// the band and guillotines the real cells. `None` = no exemption. Must
74    /// match `data_files` length when `Some`.
75    pub qc_exempt_files: Option<Vec<bool>>,
76    /// Block size for the QC streaming stat passes (`None` = default).
77    pub qc_block_size: Option<usize>,
78    /// Optional path for a per-cell QC report TSV (`None` = don't write).
79    pub qc_report_out: Option<Box<str>>,
80    /// Optional per-file feature-name (row) modality suffix, one entry per
81    /// `data_files` entry in order. When `Some`, backend `b`'s rows are
82    /// renamed `{canon(row)}/{suffix[b]}` so files sharing raw feature
83    /// names (e.g. spliced/unspliced) stay on separate rows, while the same
84    /// name + suffix (same modality across samples) still merges. `None` =
85    /// today's behavior (no suffixing). Must match `data_files` length.
86    pub per_file_feature_suffix: Option<Vec<Box<str>>>,
87    /// Optional per-file barcode (column) suffix, one entry per `data_files`
88    /// entry in order. Under [`ColumnAlignment::Union`], backend `b`'s
89    /// barcodes are tagged `{barcode}@{suffix[b]}` before the canonical merge,
90    /// so callers can encode per-cell **sample identity**: the same barcode in
91    /// two files merges into one cell only when both carry the same suffix
92    /// (same sample across modalities), while different samples stay distinct.
93    /// `None` (or a per-entry `None`) = no tag. Must match `data_files`
94    /// length. No effect under `Disjoint` (which disambiguates via
95    /// `@<basename>`).
96    pub per_file_barcode_suffix: Option<Vec<Option<Box<str>>>>,
97}
98
99/// Sparse data with per-cell batch labels.
100pub struct SparseDataWithBatch {
101    pub data: SparseIoVec,
102    pub batch: Vec<Box<str>>,
103    /// Near-empty output keep-mask: indices (in post-`mask_columns`
104    /// column order) of cells to emit at output. `None` when no QC ran
105    /// (emit every cell). See [`crate::qc_lib::QcReport::output_keep_idx`].
106    pub output_keep_idx: Option<Vec<usize>>,
107}
108
109/// Load multiple sparse data files, verify shared row names, and auto-detect batch.
110///
111/// Batch assignment priority:
112/// 1. Explicit batch files (one label per cell per file)
113/// 2. Embedded `@`-separated batch info in column names (e.g., `barcode@donor`)
114/// 3. File name as batch label (one batch per input file)
115pub fn read_data_on_shared_rows(args: ReadSharedRowsArgs) -> anyhow::Result<SparseDataWithBatch> {
116    // to avoid duplicate barcodes in the column names
117    let attach_data_name = args.data_files.len() > 1;
118
119    // Open every backend first (preserving order). For LocusOverlap we
120    // need to peek all row names before installing the canonicalizer.
121    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    // `Disjoint` keeps today's @<basename>-suffix semantics; `Union`
133    // glues cells by raw barcode (no suffix). Affects `SparseIoVec::push`
134    // *and* the batch-resolution branch below — per-file batch labels
135    // become ambiguous under Union (one cell can be in two files) so we
136    // require either a single unified batch file or per-cell embedded
137    // tags that agree across backends.
138    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    // Per-file feature-name modality suffix (e.g. `--multiome` modality
147    // namespacing). Installed before any push so backend `b`'s rows become
148    // `{canon(row)}/{suffix[b]}`.
149    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    // Per-file barcode (column) sample suffix — applied per push below under
162    // Union. Validate length up front so a mismatch fails before any I/O.
163    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    // Peek row names once if auto-detect needs them, or if the
175    // caller-specified kind needs a global cross-name pass.
176    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    // One flat list of every file's row names (the Mixed / locus-overlap
182    // maps are built over all of them at once), plus where each file's
183    // slice ends, so auto-detection can look at one file at a time without a
184    // second read.
185    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            // Per FILE, then reconciled — never over the pool. The naming
202            // signature usually lives on one side only: a raw `ENSG_SYM`
203            // cohort next to a reference already on the bare-symbol axis
204            // (a carried `pb_reference`) left the pooled gene-like share
205            // under half, so the pair sniffed as `Exact` and every gene
206            // became two rows. See `FeatureNameKind::reconcile`.
207            let names = all_names.as_ref().expect("peeked when auto");
208            // Each file's slice of the flat list, from the cumulative ends: a
209            // plain zip of starts and ends, so the slicing does not depend on
210            // the closure running in order.
211            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    // Install canonicalizer. Three paths:
228    //   * Mixed                            → per-name dispatcher (needs names).
229    //   * Locus { merge_overlapping: true } → overlap cluster map (needs names).
230    //   * Anything else                    → pure per-name closure from `canonicalize`.
231    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                // SAFETY: data_vec is empty; with_row_canonicalizer only errors
271                // if backends were already pushed.
272                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        // Per-file barcode sample tag (Union only; ignored under Disjoint).
288        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    // SparseIoVec already aligns rows to the intersection of row names
296    // across all backends; warn if any backend introduced new rows that
297    // had to be dropped.
298    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    // Soft guard: when running with the default Disjoint cell-stacking
310    // and the feature axes are mostly disjoint AND raw barcodes overlap,
311    // the inputs look multi-modal-shaped and the user probably meant
312    // `--multiome`. Print a hint, don't error — a single-modality user
313    // with quirky inputs should still get through.
314    if args.column_alignment == ColumnAlignment::Disjoint && data_vec.len() >= 2 {
315        maybe_warn_multimodal_pattern(&data_vec);
316    }
317
318    // check batch membership
319    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    // Empty-barcode gate: per-file cell calling, before QC and before any
341    // batch/group registration (mask_columns renumbers the cells). Batch
342    // labels were resolved on the full axis above (a unified batch file
343    // lists every barcode), then filtered in lockstep.
344    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    // Optional shared cell QC — applied here (before any batch/group
360    // registration, which happens later during projection) so all
361    // downstream stages see the QC-reduced axes consistently.
362    let output_keep_idx = if let Some(cfg) = args.qc.as_ref() {
363        // Columns of exempt files stay out of the QC bands and verdicts;
364        // resolved per column through the loaded vec's backend attribution so
365        // it is correct under any stacking order.
366        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        // Feature axis first (compact-row space is still the original one).
401        if report.n_features_dropped > 0 {
402            data_vec.mask_rows(&report.feature_keep)?;
403        }
404        // Cell axis: indices computed against the original column order,
405        // then mask_columns + lockstep batch filter.
406        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
423/// Keep-mask of the empty-barcode gate, or `None` when nothing is dropped.
424///
425/// Each backend is cell-called on its own column nnz (read off the resident
426/// indptr, no I/O): modalities count on different scales, so they are never
427/// pooled. A global column survives when any backend observing it passes
428/// (or is exempt, or has no indptr / no trough to call on).
429fn 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    // Never hand back an empty matrix.
487    (n_drop > 0 && n_drop < keep.len()).then_some(keep)
488}
489
490/// Soft hint when running with `ColumnAlignment::Disjoint` and inputs
491/// look like patchy multi-modal data. Logs once at WARN level; never
492/// errors.
493fn 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    // Cheap raw-barcode set-intersection across backends. `retain`
512    // shrinks the accumulator in place — no per-entry `Box<str>` clone,
513    // and an empty intersection short-circuits the remaining backends.
514    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, // backend can't list columns; skip the hint
519        };
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
548/// Existing behavior: one batch label per cell, per-file slicing.
549fn 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
603/// Union-mode batch resolution. A cell can be in multiple backends, so
604/// per-file batch labels don't compose: a barcode shared across two
605/// files cannot have two batch labels. Rules:
606///
607/// - `batch_files`: must have exactly one file, listing one label per
608///   unified cell in `data_vec.column_names()` order.
609/// - Embedded `@batch` tag in raw column names (no `@<basename>`
610///   suffix is added under Union): each backend independently infers
611///   tags; conflicts (same barcode, different tag in two backends)
612///   are an error.
613/// - Fallback: constant `"all"` for cells whose backends don't agree
614///   on an embedded tag. (File-name fallback from the Disjoint path
615///   doesn't apply — a cell can come from many files.)
616fn 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    // No batch_files: derive the per-cell @batch tag from the UNIFIED column
646    // names. Under Union the displayed name already carries any embedded
647    // `@tag` — whether from data-prep (`barcode@donor`) or from a per-file
648    // barcode suffix (`barcode@sample`, added by `push_with_barcode_suffix`).
649    // Each unified cell has exactly one name, so no cross-backend
650    // reconciliation is needed: two cells that disagreed on their tag would
651    // have different merge keys and never folded into one cell.
652    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
669/// Infer per-cell batch labels for one file's column names.
670///
671/// `appended_suffix` is the `@{file_base}` barcode disambiguator that
672/// `SparseIoVec::push` tacks onto every column when multiple files are
673/// loaded; it must be stripped *before* searching for a real embedded
674/// `@batch` tag, otherwise `rsplit('@')` picks up the basename and every
675/// cell in a file collapses to one wrong batch label.
676///
677/// Returns `(tags, used_embedded)` where `used_embedded=true` means the
678/// raw column names already contained an `@batch` tag.
679fn 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        // Simulates `SparseIoVec::push` appending `@mix` to each column name
728        // when multiple files are loaded. Raw names are `ACGT-1@donorA`,
729        // `ACGT-2@donorB`.
730        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        // Barcodes without any embedded `@`.
747        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        // Single file → no `@file_base` suffix was appended.
759        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}