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 anyhow::ensure!(
41 self.column_scales.is_none(),
42 "push after set_column_scales: the new columns would have no scale"
43 );
44 let Some(ncol_data) = data.num_columns() else {
45 return Err(anyhow::anyhow!("data file has no columns"));
46 };
47 debug_assert_eq!(self.col_to_data.len(), self.offset);
48 let didx = self.data_vec.len();
49 let didx_u32: u32 = didx
50 .try_into()
51 .map_err(|_| anyhow::anyhow!("backend count overflows u32"))?;
52
53 // Everything that can fail runs before the first change to `self`, so
54 // a refused backend leaves the vector exactly as it was and the next
55 // push starts from a consistent state.
56 let raw_col_names = data.column_names()?;
57 debug_assert_eq!(raw_col_names.len(), ncol_data);
58 anyhow::ensure!(u32::try_from(ncol_data).is_ok(), "local col overflows u32");
59 // `Union` keys its global columns by `u32`.
60 anyhow::ensure!(
61 self.column_alignment == ColumnAlignment::Disjoint
62 || u32::try_from(self.offset + ncol_data).is_ok(),
63 "global col overflows u32"
64 );
65
66 // Per-backend modality namespacing: `/{suffix}` goes after
67 // canonicalization so the gene/locus rule still applies to the
68 // bare name and only the modality tag distinguishes the row.
69 let row_suffix = match self.per_backend_row_suffix.as_ref() {
70 Some(suffixes) => Some(suffixes.get(didx).ok_or_else(|| {
71 anyhow::anyhow!(
72 "per_backend_row_suffix has {} entries but backend index is {}",
73 suffixes.len(),
74 didx,
75 )
76 })?),
77 None => None,
78 };
79 let row_keys: Vec<Box<str>> = data
80 .row_names()?
81 .iter()
82 .map(|row| {
83 let key: Box<str> = match self.row_canonicalizer.as_ref() {
84 Some(canon) => canon(row),
85 None => row.clone(),
86 };
87 match row_suffix {
88 Some(suffix) => format!("{key}/{suffix}").into_boxed_str(),
89 None => key,
90 }
91 })
92 .collect();
93
94 // `Union` only: each column's (merge key, displayed name), refused
95 // when two columns of this backend share a key — that would put two
96 // entries from the same backend on one global col.
97 let union_keys: Vec<(Box<str>, Box<str>)> = match self.column_alignment {
98 ColumnAlignment::Disjoint => Vec::new(),
99 ColumnAlignment::Union => {
100 let mut seen_in_this_push: HashMap<Box<str>, usize> =
101 HashMap::with_capacity_and_hasher(ncol_data, Default::default());
102 let mut keys = Vec::with_capacity(ncol_data);
103 for (loc, raw) in raw_col_names.iter().enumerate() {
104 // Tag the barcode with the per-file suffix (sample id)
105 // before canonicalization so the merge key — and the
106 // displayed name — carry sample identity. Same suffix
107 // across backends ⇒ merge; different ⇒ distinct cells.
108 let tagged: Box<str> = match barcode_suffix {
109 Some(s) => format!("{raw}{COLUMN_SEP}{s}").into_boxed_str(),
110 None => raw.clone(),
111 };
112 let canon: Box<str> = match self.column_canonicalizer.as_ref() {
113 Some(c) => c(&tagged),
114 None => tagged.clone(),
115 };
116 if let Some(prev_loc) = seen_in_this_push.insert(canon.clone(), loc) {
117 return Err(anyhow::anyhow!(
118 "ColumnAlignment::Union: backend {} has duplicate canonical \
119 barcode `{}` (local cols {} and {}) — Union cannot fold a \
120 cell with itself within one backend",
121 didx,
122 canon,
123 prev_loc,
124 loc,
125 ));
126 }
127 keys.push((canon, tagged));
128 }
129 keys
130 }
131 };
132
133 // `Disjoint`: every push appends fresh global columns + the
134 // `@<basename>` disambiguator. `Union`: match each pushed
135 // barcode against the existing global cell pool by canonical
136 // name so matched barcodes share a global column across
137 // backends (reads merge their nonzeros into one output col).
138 let data_to_cells_loc_to_glob: Vec<usize> = match self.column_alignment {
139 ColumnAlignment::Disjoint => {
140 let local_to_glob: Vec<usize> = (self.offset..self.offset + ncol_data).collect();
141 self.col_to_data
142 .extend((0..ncol_data as u32).map(|local_col| {
143 vec![BackendLocation {
144 backend: didx_u32,
145 local_col,
146 }]
147 }));
148 let data_tag = match data_name.as_deref() {
149 Some(x) => COLUMN_SEP.to_string() + x,
150 None => String::new(),
151 };
152 self.column_names_with_data_tag.extend(
153 raw_col_names
154 .iter()
155 .map(|x| (x.to_string() + data_tag.as_str()).into_boxed_str()),
156 );
157 self.offset += ncol_data;
158 local_to_glob
159 }
160 ColumnAlignment::Union => {
161 let mut local_to_glob = Vec::with_capacity(ncol_data);
162 for (loc, (canon, tagged)) in union_keys.into_iter().enumerate() {
163 let here = BackendLocation {
164 backend: didx_u32,
165 local_col: loc as u32,
166 };
167 let glob = match self.col_name_position.get(&canon).copied() {
168 Some(g) => {
169 self.col_to_data[g as usize].push(here);
170 g as usize
171 }
172 None => {
173 let new_g = self.offset;
174 self.col_name_position.insert(canon, new_g as u32);
175 self.col_to_data.push(vec![here]);
176 // Tagged barcode (`raw` when no suffix) becomes
177 // the displayed name; subsequent backends
178 // contributing to this cell don't relabel it.
179 self.column_names_with_data_tag.push(tagged);
180 self.offset += 1;
181 new_g
182 }
183 };
184 local_to_glob.push(glob);
185 }
186 local_to_glob
187 }
188 };
189
190 // `data_to_cols[didx][loc] = glob`. Stored separately from
191 // `col_to_data` because callers (e.g. `rows_triplets`) need the
192 // forward map per backend.
193 self.data_to_cols.insert(didx, data_to_cells_loc_to_glob);
194
195 let mut local_to_global = Vec::with_capacity(row_keys.len());
196 for key in row_keys {
197 let glob_row = match self.row_name_position.get(&key) {
198 Some(&g) => g,
199 None => {
200 let next_global = self.row_names_by_global.len();
201 self.row_name_position.insert(key.clone(), next_global);
202 self.row_names_by_global.push(key);
203 self.row_count_by_global.push(0);
204 next_global
205 }
206 };
207 local_to_global.push(glob_row);
208 }
209 // Count per-dataset *presence*, not per-local-row hits: when the
210 // row canonicalizer collapses several local rows in one file to
211 // the same global key, this dataset should still contribute
212 // exactly 1 to that global's count. Otherwise the
213 // `RowAlignment::Intersect` admit-rule (`count >= n_datasets`)
214 // silently excludes every collapsed row.
215 let mut counted: HashSet<usize> = HashSet::default();
216 for &g in &local_to_global {
217 if counted.insert(g) {
218 self.row_count_by_global[g] += 1;
219 }
220 }
221 // Flag canonicalizer-induced intra-file row merges so the read
222 // paths can sum duplicate (row, col) entries instead of emitting
223 // them twice (which breaks `from_nonzero_triplets` strictness).
224 let has_intra_merges = counted.len() != local_to_global.len();
225 self.data_has_intra_row_merges.push(has_intra_merges);
226 let mut g2l: Vec<(usize, usize)> = local_to_global
227 .iter()
228 .enumerate()
229 .map(|(l, &g)| (g, l))
230 .collect();
231 g2l.sort_unstable();
232 self.data_global_to_local_row.push(g2l);
233 self.data_local_to_global_row.push(local_to_global);
234
235 self.data_vec.push(data.clone());
236
237 self.cached_num_columns = self.offset;
238 self.recompute_row_mapping();
239
240 info!(
241 "Added {} columns ({} total); row {} = {}",
242 ncol_data,
243 self.offset,
244 match self.row_alignment {
245 RowAlignment::Intersect => "intersection",
246 RowAlignment::Union => "union",
247 },
248 self.cached_num_rows
249 );
250 Ok(())
251 }
252
253 /// Recompute the global → compact row mapping under the current
254 /// [`RowAlignment`] mode. Intersection keeps only rows present in
255 /// every backend; Union keeps every row that any backend contains.
256 /// Surviving rows get a compact index in raw-global order;
257 /// everything else maps to `None`.
258 fn recompute_row_mapping(&mut self) {
259 let n_datasets = self.data_local_to_global_row.len();
260 let n_global = self.row_names_by_global.len();
261 let min_count = match self.row_alignment {
262 RowAlignment::Intersect => n_datasets,
263 RowAlignment::Union => 1,
264 };
265
266 self.global_to_compact_row.clear();
267 self.global_to_compact_row.resize(n_global, None);
268 self.compact_to_global_row.clear();
269
270 let mut next_compact = 0usize;
271 for g in 0..n_global {
272 if self.row_count_by_global[g] >= min_count {
273 self.global_to_compact_row[g] = Some(next_compact);
274 self.compact_to_global_row.push(g);
275 next_compact += 1;
276 }
277 }
278 self.cached_num_rows = next_compact;
279 }
280
281 pub fn num_columns_by_data(&self) -> anyhow::Result<Vec<usize>> {
282 Ok(self
283 .data_vec
284 .iter()
285 .map(|d| d.num_columns().unwrap_or(0_usize))
286 .collect())
287 }
288
289 pub fn remove_backend_file(&mut self) -> anyhow::Result<()> {
290 for dat in self.data_vec.iter() {
291 dat.remove_backend_file()?;
292 }
293 Ok(())
294 }
295}