data_beans/sparse_io_vector/push.rs
1#![allow(dead_code)]
2
3use super::*;
4
5impl SparseIoVec {
6 /// Add a backend's columns to the vector.
7 ///
8 /// * `data`: `Arc` to the backend [`SparseData`].
9 /// * `data_name`: under [`ColumnAlignment::Disjoint`], appended as the
10 /// `@<data_name>` display disambiguator for barcodes shared across
11 /// files. **Ignored under [`ColumnAlignment::Union`]**, where cells
12 /// glue by raw barcode — use [`push_with_barcode_suffix`] to attach a
13 /// per-cell sample tag that participates in the Union merge.
14 ///
15 /// [`push_with_barcode_suffix`]: Self::push_with_barcode_suffix
16 pub fn push(
17 &mut self,
18 data: Arc<SparseData>,
19 data_name: Option<Box<str>>,
20 ) -> anyhow::Result<()> {
21 self.push_with_barcode_suffix(data, data_name, None)
22 }
23
24 /// Like [`push`](Self::push) but, under [`ColumnAlignment::Union`], tags
25 /// every barcode of this backend with `{COLUMN_SEP}{barcode_suffix}`
26 /// **before** the canonical-merge step. Two backends that share a barcode
27 /// merge into one global cell only if they carry the SAME suffix, so
28 /// callers can encode per-file sample identity (e.g. `rep1_wt`):
29 /// same-sample modalities merge, different samples stay distinct. The
30 /// tagged name also becomes the displayed column name. `None` reproduces
31 /// [`push`](Self::push) exactly. The `data_name` Disjoint disambiguator is
32 /// orthogonal: it still applies under `Disjoint`, and `barcode_suffix`
33 /// only under `Union` (the two alignments are mutually exclusive).
34 pub fn push_with_barcode_suffix(
35 &mut self,
36 data: Arc<SparseData>,
37 data_name: Option<Box<str>>,
38 barcode_suffix: Option<&str>,
39 ) -> anyhow::Result<()> {
40 let Some(ncol_data) = data.num_columns() else {
41 return Err(anyhow::anyhow!("data file has no columns"));
42 };
43 debug_assert_eq!(self.col_to_data.len(), self.offset);
44 let didx = self.data_vec.len();
45 let didx_u32: u32 = didx
46 .try_into()
47 .map_err(|_| anyhow::anyhow!("backend count overflows u32"))?;
48 let raw_col_names = data.column_names()?;
49 debug_assert_eq!(raw_col_names.len(), ncol_data);
50
51 // `Disjoint`: every push appends fresh global columns + the
52 // `@<basename>` disambiguator. `Union`: match each pushed
53 // barcode against the existing global cell pool by canonical
54 // name so matched barcodes share a global column across
55 // backends (reads merge their nonzeros into one output col).
56 let data_to_cells_loc_to_glob: Vec<usize> = match self.column_alignment {
57 ColumnAlignment::Disjoint => {
58 let mut local_to_glob = Vec::with_capacity(ncol_data);
59 for loc in 0..ncol_data {
60 let glob = self.offset + loc;
61 let loc_u32: u32 = loc
62 .try_into()
63 .map_err(|_| anyhow::anyhow!("local col overflows u32"))?;
64 self.col_to_data.push(vec![BackendLocation {
65 backend: didx_u32,
66 local_col: loc_u32,
67 }]);
68 local_to_glob.push(glob);
69 }
70 let data_tag = match data_name.as_deref() {
71 Some(x) => COLUMN_SEP.to_string() + x,
72 None => String::new(),
73 };
74 self.column_names_with_data_tag.extend(
75 raw_col_names
76 .iter()
77 .map(|x| (x.to_string() + data_tag.as_str()).into_boxed_str()),
78 );
79 self.offset += ncol_data;
80 local_to_glob
81 }
82 ColumnAlignment::Union => {
83 // First pass: detect within-backend duplicate barcodes
84 // (would create a malformed global col with two entries
85 // from the same backend).
86 let mut seen_in_this_push: HashMap<Box<str>, usize> =
87 HashMap::with_capacity_and_hasher(ncol_data, Default::default());
88 let mut local_to_glob = Vec::with_capacity(ncol_data);
89 for (loc, raw) in raw_col_names.iter().enumerate() {
90 // Tag the barcode with the per-file suffix (sample id)
91 // before canonicalization so the merge key — and the
92 // displayed name — carry sample identity. Same suffix
93 // across backends ⇒ merge; different ⇒ distinct cells.
94 // Borrow `raw` in the common no-suffix path so the merge
95 // pass doesn't allocate a `Box<str>` per column.
96 let tagged: Cow<str> = match barcode_suffix {
97 Some(s) => Cow::Owned(format!("{raw}{COLUMN_SEP}{s}")),
98 None => Cow::Borrowed(raw.as_ref()),
99 };
100 let canon: Box<str> = match self.column_canonicalizer.as_ref() {
101 Some(c) => c(&tagged),
102 None => Box::from(&*tagged),
103 };
104 if let Some(&prev_loc) = seen_in_this_push.get(&canon) {
105 return Err(anyhow::anyhow!(
106 "ColumnAlignment::Union: backend {} has duplicate canonical \
107 barcode `{}` (local cols {} and {}) — Union cannot fold a \
108 cell with itself within one backend",
109 didx,
110 canon,
111 prev_loc,
112 loc,
113 ));
114 }
115 seen_in_this_push.insert(canon.clone(), loc);
116
117 let loc_u32: u32 = loc
118 .try_into()
119 .map_err(|_| anyhow::anyhow!("local col overflows u32"))?;
120 let glob = match self.col_name_position.get(&canon).copied() {
121 Some(g) => {
122 self.col_to_data[g as usize].push(BackendLocation {
123 backend: didx_u32,
124 local_col: loc_u32,
125 });
126 g as usize
127 }
128 None => {
129 let new_g = self.offset;
130 let new_g_u32: u32 = new_g
131 .try_into()
132 .map_err(|_| anyhow::anyhow!("global col overflows u32"))?;
133 self.col_name_position.insert(canon, new_g_u32);
134 self.col_to_data.push(vec![BackendLocation {
135 backend: didx_u32,
136 local_col: loc_u32,
137 }]);
138 // Tagged barcode (`raw` when no suffix) becomes
139 // the displayed name; subsequent backends
140 // contributing to this cell don't relabel it.
141 self.column_names_with_data_tag.push(Box::from(&*tagged));
142 self.offset += 1;
143 new_g
144 }
145 };
146 local_to_glob.push(glob);
147 }
148 local_to_glob
149 }
150 };
151
152 // `data_to_cols[didx][loc] = glob`. Stored separately from
153 // `col_to_data` because callers (e.g. `rows_triplets`) need the
154 // forward map per backend.
155 let entry = self.data_to_cols.entry(didx).or_default();
156 entry.extend(data_to_cells_loc_to_glob.iter().copied());
157
158 let row_names = data.row_names()?;
159 let mut local_to_global = Vec::with_capacity(row_names.len());
160 for row in row_names.iter() {
161 let mut key: Box<str> = match self.row_canonicalizer.as_ref() {
162 Some(canon) => canon(row),
163 None => row.clone(),
164 };
165 // Per-backend modality namespacing: append `/{suffix}` after
166 // canonicalization so the gene/locus rule still applies to the
167 // bare name and only the modality tag distinguishes the row.
168 if let Some(suffixes) = self.per_backend_row_suffix.as_ref() {
169 let suffix = suffixes.get(didx).ok_or_else(|| {
170 anyhow::anyhow!(
171 "per_backend_row_suffix has {} entries but backend index is {}",
172 suffixes.len(),
173 didx,
174 )
175 })?;
176 key = format!("{key}/{suffix}").into_boxed_str();
177 }
178 let glob_row = match self.row_name_position.get(&key) {
179 Some(&g) => g,
180 None => {
181 let next_global = self.row_names_by_global.len();
182 self.row_name_position.insert(key.clone(), next_global);
183 self.row_names_by_global.push(key);
184 self.row_count_by_global.push(0);
185 next_global
186 }
187 };
188 local_to_global.push(glob_row);
189 }
190 // Count per-dataset *presence*, not per-local-row hits: when the
191 // row canonicalizer collapses several local rows in one file to
192 // the same global key, this dataset should still contribute
193 // exactly 1 to that global's count. Otherwise the
194 // `RowAlignment::Intersect` admit-rule (`count >= n_datasets`)
195 // silently excludes every collapsed row.
196 let mut counted: HashSet<usize> = HashSet::default();
197 for &g in &local_to_global {
198 if counted.insert(g) {
199 self.row_count_by_global[g] += 1;
200 }
201 }
202 // Flag canonicalizer-induced intra-file row merges so the read
203 // paths can sum duplicate (row, col) entries instead of emitting
204 // them twice (which breaks `from_nonzero_triplets` strictness).
205 let has_intra_merges = counted.len() != local_to_global.len();
206 self.data_has_intra_row_merges.push(has_intra_merges);
207 let mut g2l: HashMap<usize, usize> =
208 HashMap::with_capacity_and_hasher(local_to_global.len(), Default::default());
209 for (l, &g) in local_to_global.iter().enumerate() {
210 g2l.insert(g, l);
211 }
212 self.data_global_to_local_row.push(g2l);
213 self.data_local_to_global_row.push(local_to_global);
214
215 self.data_vec.push(data.clone());
216
217 self.cached_num_columns = self.offset;
218 self.recompute_row_mapping();
219
220 info!(
221 "Added {} columns ({} total); row {} = {}",
222 ncol_data,
223 self.offset,
224 match self.row_alignment {
225 RowAlignment::Intersect => "intersection",
226 RowAlignment::Union => "union",
227 },
228 self.cached_num_rows
229 );
230 Ok(())
231 }
232
233 /// Recompute the global → compact row mapping under the current
234 /// [`RowAlignment`] mode. Intersection keeps only rows present in
235 /// every backend; Union keeps every row that any backend contains.
236 /// Surviving rows get a compact index in raw-global order;
237 /// everything else maps to `None`.
238 fn recompute_row_mapping(&mut self) {
239 let n_datasets = self.data_local_to_global_row.len();
240 let n_global = self.row_names_by_global.len();
241 let min_count = match self.row_alignment {
242 RowAlignment::Intersect => n_datasets,
243 RowAlignment::Union => 1,
244 };
245
246 self.global_to_compact_row.clear();
247 self.global_to_compact_row.resize(n_global, None);
248 self.compact_to_global_row.clear();
249
250 let mut next_compact = 0usize;
251 for g in 0..n_global {
252 if self.row_count_by_global[g] >= min_count {
253 self.global_to_compact_row[g] = Some(next_compact);
254 self.compact_to_global_row.push(g);
255 next_compact += 1;
256 }
257 }
258 self.cached_num_rows = next_compact;
259 }
260
261 pub fn num_columns_by_data(&self) -> anyhow::Result<Vec<usize>> {
262 Ok(self
263 .data_vec
264 .iter()
265 .map(|d| d.num_columns().unwrap_or(0_usize))
266 .collect())
267 }
268
269 pub fn remove_backend_file(&mut self) -> anyhow::Result<()> {
270 for dat in self.data_vec.iter() {
271 dat.remove_backend_file()?;
272 }
273 Ok(())
274 }
275}