Skip to main content

data_beans/sparse_io_vector/
mod.rs

1#![allow(dead_code)]
2
3use crate::sparse_io::*;
4
5use indicatif::{ParallelProgressIterator, ProgressIterator};
6use legume_numeric::matrix::knn_match::ColumnDict;
7use legume_numeric::matrix::knn_match::MakeVecPoint;
8use legume_numeric::matrix::traits::*;
9use legume_numeric::matrix::utils::*;
10use log::info;
11use rayon::prelude::*;
12use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
13use std::ops::Index;
14use std::sync::Arc;
15
16// `impl SparseIoVec` is split across sibling modules by concern. Inherent
17// impls compose across modules, so every method stays callable at the same
18// path; siblings see the struct's private fields as descendant modules and
19// inherit this module's imports via `use super::*`.
20mod batch;
21mod groups;
22mod matched;
23mod push;
24mod read;
25pub mod scales;
26
27/// The `data_name` [`SparseIoVec::push`] takes for a backend read from
28/// `file`, one of `n_files` stacked side by side
29/// ([`ColumnAlignment::Disjoint`]): the file's stem (`a/pbmc.zarr.zip` →
30/// `pbmc`) when there are several, so cells sharing a barcode across files
31/// stay apart, and none for a single file. `read_data_on_shared_rows` and
32/// the CLI's readers name cells by this rule.
33pub fn data_name_of(file: &str, n_files: usize) -> anyhow::Result<Option<Box<str>>> {
34    (n_files > 1)
35        .then(|| legume_numeric::matrix::common_io::basename(file))
36        .transpose()
37}
38
39/// The name a cell `name` of `file` goes by once `n_files` files are stacked
40/// side by side: `name@{stem}` for several files ([`data_name_of`]), `name`
41/// for one.
42pub fn stacked_column_name(name: &str, file: &str, n_files: usize) -> anyhow::Result<Box<str>> {
43    Ok(tag_column_name(
44        name,
45        data_name_of(file, n_files)?.as_deref(),
46    ))
47}
48
49/// `name`, with `@{data_name}` when there is one.
50fn tag_column_name(name: &str, data_name: Option<&str>) -> Box<str> {
51    match data_name {
52        Some(tag) => format!("{name}{COLUMN_SEP}{tag}").into_boxed_str(),
53        None => name.into(),
54    }
55}
56
57/// Where a single global cell's nonzeros come from in one of the
58/// underlying [`SparseIo`] backends. Under
59/// [`ColumnAlignment::Disjoint`] each global column has exactly one
60/// `BackendLocation`. Under [`ColumnAlignment::Union`] a global column
61/// can carry one entry per backend that observed the cell.
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub struct BackendLocation {
64    /// Index into [`SparseIoVec::data_vec`].
65    pub backend: u32,
66    /// Local column index within that backend.
67    pub local_col: u32,
68}
69
70type SparseData = dyn SparseIo<IndexIter = Vec<usize>>;
71
72/// Optional canonicalizer applied to every backend row name during
73/// `push`. Two raw names that produce the same canonical form are
74/// treated as the same row. The displayed `row_names_by_global` stores
75/// the canonical form, so downstream readers see a single consistent
76/// label for each row across all backends. Default = `None` preserves
77/// exact-match behavior.
78pub type RowNameCanonicalizer = Arc<dyn Fn(&str) -> Box<str> + Send + Sync>;
79
80/// Optional canonicalizer applied to every backend *column* (barcode)
81/// name during `push` under [`ColumnAlignment::Union`]. Mirror of
82/// [`RowNameCanonicalizer`]. Two raw barcodes whose canonical forms
83/// match are treated as the same biological cell — backends share a
84/// single global column id, and `read_columns_*` merges their nonzeros
85/// into one output column. Default = `None` preserves exact-match
86/// behavior.
87pub type ColumnNameCanonicalizer = Arc<dyn Fn(&str) -> Box<str> + Send + Sync>;
88
89/// The lazily-built group- and batch-membership caches for a [`SparseIoVec`],
90/// grouped into one field so the three places that reset them (`new`,
91/// `mask_columns`, and clone-for-collapse) each collapse to a single
92/// assignment.
93///
94/// **Cloning drops every cache** — `Clone` returns [`Default`]. The caches are
95/// derived state, re-registered from scratch by `assign_groups` /
96/// `register_batch_membership` / the collapse, so a cloned `SparseIoVec` starts
97/// from the correct pre-collapse state. Isolating the non-`Clone` HNSW indices
98/// in `batch_knn_lookup` here is also what lets the rest of `SparseIoVec`
99/// `#[derive(Clone)]`. Do not rely on `.clone()` preserving these.
100#[derive(Default)]
101struct DerivedCaches {
102    // group caches (built by `assign_groups`)
103    // per column, `usize::MAX` for a column left out of every group
104    col_to_group: Option<Vec<usize>>,
105    group_to_cols: Option<Vec<Vec<usize>>>,
106    group_keys: Option<Vec<Box<str>>>,
107    // batch caches (built by `register_batches` / `register_batch_membership`)
108    batch_knn_lookup: Option<Vec<ColumnDict<usize>>>,
109    col_to_batch: Option<Vec<usize>>,
110    batch_to_cols: Option<Vec<Vec<usize>>>,
111    batch_idx_to_name: Option<Vec<Box<str>>>,
112    between_batch_proximity: Option<Vec<Vec<usize>>>,
113}
114
115impl Clone for DerivedCaches {
116    /// Drops all caches (returns [`Default`]); see the type docs. They rebuild
117    /// lazily, so a cloned `SparseIoVec` is the correct fresh pre-collapse view.
118    fn clone(&self) -> Self {
119        Self::default()
120    }
121}
122
123#[derive(Clone)]
124pub struct SparseIoVec {
125    data_vec: Vec<Arc<SparseData>>,
126    /// Per global column → one or more [`BackendLocation`] entries.
127    /// Under [`ColumnAlignment::Disjoint`] each inner `Vec` always
128    /// has length 1. Under [`ColumnAlignment::Union`] a global
129    /// column can be observed by multiple backends — one entry per
130    /// backend that contributes triplets for that cell.
131    col_to_data: Vec<Vec<BackendLocation>>,
132    data_to_cols: HashMap<usize, Vec<usize>>,
133    offset: usize,
134    // Row-name alignment across backends. Each backend may have a
135    // different subset/ordering of rows; we keep only the intersection
136    // and remap local indices through `data_local_to_global_row` and
137    // `global_to_compact_row` at read time.
138    row_canonicalizer: Option<RowNameCanonicalizer>,
139    row_name_position: HashMap<Box<str>, usize>,
140    row_names_by_global: Vec<Box<str>>,
141    data_local_to_global_row: Vec<Vec<usize>>,
142    /// Per-backend inverse of `data_local_to_global_row[didx]`: every
143    /// `(global, local)` row pair, sorted, so the local rows of one global
144    /// row are a contiguous run found by binary search -- more than one where
145    /// the canonicalizer folded rows together. Built once at `push` time,
146    /// never mutated thereafter, so row reads don't have to rebuild it per
147    /// call.
148    data_global_to_local_row: Vec<Vec<(usize, usize)>>,
149    /// `true` for datasets where `local_to_global` is non-injective —
150    /// i.e. the canonicalizer collapsed two or more local rows to the
151    /// same global row. Read paths use this to switch from a fast
152    /// pass-through emit to a `(row, col) → sum` merge so the resulting
153    /// COO doesn't carry duplicate entries (which break CSC builders).
154    data_has_intra_row_merges: Vec<bool>,
155    row_count_by_global: Vec<usize>,
156    global_to_compact_row: Vec<Option<usize>>,
157    /// Inverse of `global_to_compact_row` restricted to compact rows.
158    /// `compact_to_global_row[c]` is the raw-global index for compact `c`.
159    /// Kept in sync with `global_to_compact_row` whenever it changes.
160    compact_to_global_row: Vec<usize>,
161    column_names_with_data_tag: Vec<Box<str>>,
162    /// `Union` mode only: canonical barcode → global column id. Populated
163    /// at push time so subsequent pushes can match cells across backends.
164    /// Unused (empty) under `Disjoint`.
165    col_name_position: HashMap<Box<str>, u32>,
166    /// Lazily-built group/batch membership caches; dropped on clone. See
167    /// [`DerivedCaches`].
168    derived: DerivedCaches,
169    cached_num_rows: usize,
170    cached_num_columns: usize,
171    /// How row names align across pushed backends. Default
172    /// [`RowAlignment::Union`] keeps every row from any backend, with
173    /// cells from a backend that doesn't contain row `g` implicitly
174    /// observing zero at that position — used for multi-modal data
175    /// where features (e.g. peaks vs genes) are disjoint.
176    /// [`RowAlignment::Intersect`] reverts to the historical
177    /// "common rows only" semantics.
178    row_alignment: RowAlignment,
179    /// How column (cell) names align across pushed backends. Default
180    /// [`ColumnAlignment::Disjoint`] preserves the historical
181    /// concatenate-cells semantics, with `@<basename>` suffixing when
182    /// multiple files are loaded. [`ColumnAlignment::Union`] matches
183    /// cells by canonical barcode across backends and lets one global
184    /// column carry triplets from multiple backends — the foundation
185    /// for patchy multi-modal (multiome) integration.
186    column_alignment: ColumnAlignment,
187    /// Optional canonicalizer for barcode matching under `Union` mode.
188    /// See [`ColumnNameCanonicalizer`].
189    column_canonicalizer: Option<ColumnNameCanonicalizer>,
190    /// Optional per-backend feature-name (row) suffix, indexed by push
191    /// order (`didx`). When set, every row of backend `b` is renamed
192    /// `{canon(row)}/{suffix[b]}` *after* canonicalization — so the
193    /// canonical gene/locus rule still applies to the bare name, and the
194    /// suffix only namespaces it onto a modality-specific row. Two
195    /// backends sharing a raw name (e.g. spliced vs unspliced `TSPAN6`)
196    /// thus stay separate, while the same name + same suffix (same
197    /// modality across donors) still merges. `None` = no suffixing.
198    per_backend_row_suffix: Option<Vec<Box<str>>>,
199    /// Per global column, the factor every read divides it by
200    /// ([`Self::set_column_scales`]). `None` reads the counts as stored.
201    column_scales: Option<Vec<f32>>,
202    /// Per global column, how many observations it stands for
203    /// ([`Self::register_column_multiplicity`]); `None` means one apiece.
204    /// Declared by the caller and derivable from nothing else, so unlike
205    /// [`DerivedCaches`] it survives cloning and
206    /// [`Self::clear_column_membership`], and [`Self::mask_columns`] keeps the
207    /// surviving columns' weights.
208    col_multiplicity: Option<Vec<f32>>,
209}
210
211/// Strategy for aligning row names across multiple pushed backends.
212#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
213pub enum RowAlignment {
214    /// Keep every row from any backend; non-observing backends emit zero
215    /// at that position. The default — strictly more permissive than
216    /// intersection: it reduces to the same result when all files share
217    /// their row set, and enables multi-modal load (e.g. peaks ∪ genes)
218    /// when they don't.
219    #[default]
220    Union,
221    /// Keep only rows present in every backend. Opt-in for callers that
222    /// want strict single-modality semantics.
223    Intersect,
224}
225
226/// Strategy for aligning column (cell) names across multiple pushed
227/// backends.
228#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
229pub enum ColumnAlignment {
230    /// Today's behavior. Each push appends `ncol_data` brand-new global
231    /// cells; raw barcodes get `@<basename>` suffixed for disambiguation
232    /// when more than one backend is pushed. Two backends naming the
233    /// same biological cell stay disjoint.
234    #[default]
235    Disjoint,
236    /// Match each pushed column's canonical barcode against existing
237    /// global cells. New barcodes extend the cell pool; matched barcodes
238    /// share one global column across backends (the column carries
239    /// triplets from every backend that observes the cell). No
240    /// `@<basename>` suffix. Foundation for patchy multi-modal
241    /// (multiome) integration.
242    Union,
243}
244
245pub struct TripletsMatched {
246    pub shape: (usize, usize),
247    pub triplets: Vec<(u64, u64, f32)>,
248    pub source_columns: Vec<usize>,
249    pub matched_columns: Vec<usize>,
250    pub distances: Vec<f32>,
251}
252
253/// The backend pushed `idx`-th, as stored: reads through it bypass the row
254/// alignment and the column scales ([`SparseIoVec::set_column_scales`]).
255impl Index<usize> for SparseIoVec {
256    type Output = Arc<SparseData>;
257    fn index(&self, idx: usize) -> &Self::Output {
258        &self.data_vec[idx]
259    }
260}
261
262impl Default for SparseIoVec {
263    /// an empty sparse io vector for horizontal data integration
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269impl SparseIoVec {
270    /// an empty sparse io vector for horizontal data integration
271    pub fn new() -> Self {
272        Self {
273            data_vec: vec![],
274            col_to_data: vec![],
275            data_to_cols: HashMap::default(),
276            offset: 0,
277            row_canonicalizer: None,
278            row_name_position: HashMap::default(),
279            row_names_by_global: vec![],
280            data_local_to_global_row: vec![],
281            data_global_to_local_row: vec![],
282            data_has_intra_row_merges: vec![],
283            row_count_by_global: vec![],
284            global_to_compact_row: vec![],
285            compact_to_global_row: vec![],
286            column_names_with_data_tag: vec![],
287            col_name_position: HashMap::default(),
288            derived: DerivedCaches::default(),
289            cached_num_rows: 0,
290            cached_num_columns: 0,
291            row_alignment: RowAlignment::default(),
292            column_alignment: ColumnAlignment::default(),
293            column_canonicalizer: None,
294            per_backend_row_suffix: None,
295            column_scales: None,
296            col_multiplicity: None,
297        }
298    }
299
300    /// Switch row-name alignment between intersection (default) and union.
301    /// Must be called BEFORE any push — the row-mapping recompute uses
302    /// the current value of `row_alignment`. Errors if any backend has
303    /// already been added.
304    pub fn with_row_alignment(mut self, mode: RowAlignment) -> anyhow::Result<Self> {
305        anyhow::ensure!(
306            self.data_vec.is_empty(),
307            "row alignment must be set before any push"
308        );
309        self.row_alignment = mode;
310        Ok(self)
311    }
312
313    /// Install a row-name canonicalizer for fuzzy cross-backend row
314    /// alignment. Must be called BEFORE the first [`push`](Self::push) —
315    /// returns an error if any backend has already been added (the
316    /// existing `row_names_by_global` would otherwise mix raw and
317    /// canonicalized forms).
318    ///
319    /// Typical use: pass a `GeneIndexResolver`-style canonicalizer so
320    /// `ENSG00000000003_TSPAN6` (file A) and `TSPAN6` (file B) collapse
321    /// to a single row, instead of being silently dropped from the
322    /// shared-row intersection.
323    pub fn with_row_canonicalizer(
324        mut self,
325        canon: impl Fn(&str) -> Box<str> + Send + Sync + 'static,
326    ) -> anyhow::Result<Self> {
327        anyhow::ensure!(
328            self.data_vec.is_empty(),
329            "row canonicalizer must be set before any push"
330        );
331        self.row_canonicalizer = Some(Arc::new(canon));
332        Ok(self)
333    }
334
335    /// Install a per-backend feature-name suffix (one entry per backend,
336    /// in push order). Each backend `b`'s rows are renamed
337    /// `{canon(row)}/{suffix[b]}`, so files sharing raw feature names stay
338    /// on separate rows unless they also share the suffix (same modality).
339    /// Must be called BEFORE the first [`push`](Self::push). The vec length
340    /// must match the number of backends that will be pushed; `push` errors
341    /// if `didx` is out of range.
342    pub fn with_per_backend_row_suffix(mut self, suffix: Vec<Box<str>>) -> anyhow::Result<Self> {
343        anyhow::ensure!(
344            self.data_vec.is_empty(),
345            "per-backend row suffix must be set before any push"
346        );
347        self.per_backend_row_suffix = Some(suffix);
348        Ok(self)
349    }
350
351    /// Switch column (cell) alignment between disjoint concatenation
352    /// (default) and barcode-keyed union. Must be called BEFORE any
353    /// push — the push branches off the current value. Errors if any
354    /// backend has already been added.
355    pub fn with_column_alignment(mut self, mode: ColumnAlignment) -> anyhow::Result<Self> {
356        anyhow::ensure!(
357            self.data_vec.is_empty(),
358            "column alignment must be set before any push"
359        );
360        self.column_alignment = mode;
361        Ok(self)
362    }
363
364    /// Install a column-name canonicalizer for fuzzy cross-backend
365    /// barcode matching under [`ColumnAlignment::Union`]. Must be
366    /// called BEFORE the first [`push`](Self::push) — returns an error
367    /// if any backend has already been added. Has no effect under
368    /// [`ColumnAlignment::Disjoint`] (barcodes are never compared
369    /// across backends in that mode).
370    pub fn with_column_canonicalizer(
371        mut self,
372        canon: impl Fn(&str) -> Box<str> + Send + Sync + 'static,
373    ) -> anyhow::Result<Self> {
374        anyhow::ensure!(
375            self.data_vec.is_empty(),
376            "column canonicalizer must be set before any push"
377        );
378        self.column_canonicalizer = Some(Arc::new(canon));
379        Ok(self)
380    }
381
382    /// number of data sets
383    pub fn len(&self) -> usize {
384        self.data_vec.len()
385    }
386
387    /// check if the vector is empty
388    pub fn is_empty(&self) -> bool {
389        self.data_vec.is_empty()
390    }
391
392    pub fn num_rows(&self) -> usize {
393        self.cached_num_rows
394    }
395
396    /// Number of canonical rows observed by **at least** `k` of the
397    /// pushed backends. Useful for detecting multi-modal-shaped inputs
398    /// (`num_rows_in_at_least(n_backends)` is the strict intersection
399    /// size; comparing it against per-backend row counts reveals how
400    /// disjoint the feature axes are).
401    pub fn num_rows_in_at_least(&self, k: usize) -> usize {
402        self.row_count_by_global.iter().filter(|&&c| c >= k).count()
403    }
404
405    /// Current column-alignment mode. Mirrors [`Self::row_alignment`].
406    pub fn column_alignment(&self) -> ColumnAlignment {
407        self.column_alignment
408    }
409
410    /// Per-backend row coverage on the exposed (compact) row axis:
411    /// `coverage[d][r]` is true when backend `d` measures row `r`.
412    ///
413    /// Under [`RowAlignment::Union`] a backend with a smaller panel simply
414    /// has no entry at the rows it lacks — reads return zero there, which is
415    /// indistinguishable from "measured, and absent". This is the map that
416    /// lets a consumer tell the two apart: unmeasured is *no evidence*, not
417    /// evidence of zero.
418    ///
419    /// `None` when every backend covers every row (single backend, identical
420    /// panels, or intersect alignment) — the common case, so callers can skip
421    /// observability handling entirely on `None`.
422    #[must_use]
423    pub fn row_coverage_by_backend(&self) -> Option<Vec<Vec<bool>>> {
424        let n = self.cached_num_rows;
425        let mut coverage = vec![vec![false; n]; self.data_vec.len()];
426        let mut any_gap = false;
427        for (d, locals) in self.data_local_to_global_row.iter().enumerate() {
428            for &g in locals {
429                if let Some(r) = self.global_to_compact_row[g] {
430                    coverage[d][r] = true;
431                }
432            }
433            any_gap |= coverage[d].iter().any(|&c| !c);
434        }
435        any_gap.then_some(coverage)
436    }
437
438    /// The single backend a global column comes from, or `None` when the
439    /// column merges several backends (column-union alignment). Observability
440    /// accounting needs one source per column; a merged column has a *set* of
441    /// panels, which callers must handle (or refuse) explicitly.
442    #[must_use]
443    pub fn column_source(&self, col: usize) -> Option<usize> {
444        match self.col_to_data.get(col)?.as_slice() {
445            [one] => Some(one.backend as usize),
446            _ => None,
447        }
448    }
449
450    /// Every backend location of global column `col`: one entry under
451    /// `Disjoint`, one per observing backend under `Union`. Empty when out
452    /// of range.
453    #[must_use]
454    pub fn column_locations(&self, col: usize) -> &[BackendLocation] {
455        self.col_to_data.get(col).map_or(&[], Vec::as_slice)
456    }
457
458    pub fn num_non_zeros(&self) -> anyhow::Result<usize> {
459        let mut ret = 0;
460        for dat in self.data_vec.iter() {
461            let nnz = dat
462                .num_non_zeros()
463                .ok_or(anyhow::anyhow!("can't figure out the number of non-zeros"))?;
464            ret += nnz;
465        }
466        Ok(ret)
467    }
468
469    /// total number of columns across all data files
470    pub fn num_columns(&self) -> usize {
471        self.cached_num_columns
472    }
473
474    /// Structural clone for an independent projection / collapse pass.
475    ///
476    /// Named entry point for the collapse copy; it is exactly `self.clone()`,
477    /// kept as a method so call sites read as intent. Cloning copies the data
478    /// handles (matrices are `Arc`-shared, so only the index `Vec`s/`HashMap`s
479    /// are duplicated — cheap), the row/column alignment state and the
480    /// per-column scales and multiplicities, while
481    /// **dropping** every derived batch / group / HNSW cache — see
482    /// [`DerivedCaches`], whose drop-on-clone behavior is what isolates the
483    /// non-`Clone` `batch_knn_lookup` HNSW indices and lets `SparseIoVec`
484    /// `#[derive(Clone)]` at all. Those caches are re-registered from scratch
485    /// by `register_batch_membership` / the collapse, so a fresh clone is the
486    /// correct pre-collapse state.
487    ///
488    /// Prefer this name over a bare `.clone()` at collapse sites; note that any
489    /// `.clone()` is likewise lossy on the derived caches.
490    ///
491    /// Intended use: `let mut spliced = vec.clone_for_collapse();
492    /// spliced.mask_rows(&spliced_keep)?;` — gives a spliced-only view that
493    /// drives RP + collapse + refinement without disturbing the full backend
494    /// (still needed at all rows for per-modality aggregation).
495    pub fn clone_for_collapse(&self) -> Self {
496        // `Clone` drops the derived group/batch caches (see `DerivedCaches`) —
497        // exactly the fresh pre-collapse state this method documents.
498        self.clone()
499    }
500
501    /// Exclude rows (genes) from the working set. `keep[compact_row]`
502    /// is `true` for rows to keep, `false` for rows to exclude.
503    /// The compact row indices are renumbered after filtering.
504    /// This affects all downstream operations (projection, collapse,
505    /// training, inference).
506    pub fn mask_rows(&mut self, keep: &[bool]) -> anyhow::Result<()> {
507        let n_compact = self.cached_num_rows;
508        if keep.len() != n_compact {
509            return Err(anyhow::anyhow!(
510                "mask_rows: keep.len()={} != num_rows={}",
511                keep.len(),
512                n_compact
513            ));
514        }
515        // Build old_compact → new_compact mapping
516        let mut old_to_new: Vec<Option<usize>> = vec![None; n_compact];
517        let mut next = 0usize;
518        for (old, &k) in keep.iter().enumerate() {
519            if k {
520                old_to_new[old] = Some(next);
521                next += 1;
522            }
523        }
524        // Update global_to_compact_row
525        for entry in self.global_to_compact_row.iter_mut() {
526            *entry = entry.and_then(|old_compact| old_to_new[old_compact]);
527        }
528        self.cached_num_rows = next;
529        self.compact_to_global_row.clear();
530        self.compact_to_global_row.resize(next, 0);
531        for (g, &c_opt) in self.global_to_compact_row.iter().enumerate() {
532            if let Some(c) = c_opt {
533                self.compact_to_global_row[c] = g;
534            }
535        }
536        log::info!(
537            "mask_rows: {} → {} rows ({} excluded)",
538            n_compact,
539            next,
540            n_compact - next
541        );
542        Ok(())
543    }
544
545    /// Exclude columns (cells) from the working set. `keep[global_col]`
546    /// is `true` for cells to keep, `false` to exclude. Global column
547    /// indices are renumbered after filtering. This affects all
548    /// downstream operations (projection, collapse, training, inference).
549    ///
550    /// Cell-axis mirror of [`Self::mask_rows`]. MUST be called before
551    /// batch/group registration: `register_batch_membership` and group
552    /// assignment index by global column id and would be corrupted by a
553    /// renumber, so we `debug_assert!` they are unset and defensively
554    /// clear them. Dropped cells leave their backend-local columns in
555    /// place but unmapped (`usize::MAX` in `data_to_cols`), which the
556    /// row-wise read path (`rows_triplets`) skips.
557    pub fn mask_columns(&mut self, keep: &[bool]) -> anyhow::Result<()> {
558        let n = self.cached_num_columns;
559        if keep.len() != n {
560            return Err(anyhow::anyhow!(
561                "mask_columns: keep.len()={} != num_columns={}",
562                keep.len(),
563                n
564            ));
565        }
566        debug_assert!(
567            self.derived.col_to_batch.is_none() && self.derived.col_to_group.is_none(),
568            "mask_columns must be called before batch/group registration"
569        );
570
571        // old_global → Some(new_global) | None (dropped)
572        let mut old_to_new: Vec<Option<usize>> = vec![None; n];
573        let mut next = 0usize;
574        for (old, &k) in keep.iter().enumerate() {
575            if k {
576                old_to_new[old] = Some(next);
577                next += 1;
578            }
579        }
580
581        // Compact col_to_data + column_names_with_data_tag in one pass.
582        let mut new_col_to_data: Vec<Vec<BackendLocation>> = Vec::with_capacity(next);
583        let mut new_names: Vec<Box<str>> = Vec::with_capacity(next);
584        for (old, &k) in keep.iter().enumerate() {
585            if k {
586                new_col_to_data.push(std::mem::take(&mut self.col_to_data[old]));
587                new_names.push(self.column_names_with_data_tag[old].clone());
588            }
589        }
590        self.col_to_data = new_col_to_data;
591        self.column_names_with_data_tag = new_names;
592        for per_column in [&mut self.column_scales, &mut self.col_multiplicity] {
593            if let Some(values) = per_column.as_mut() {
594                *values = values
595                    .iter()
596                    .zip(keep)
597                    .filter(|(_, &k)| k)
598                    .map(|(&v, _)| v)
599                    .collect();
600            }
601        }
602
603        // Remap data_to_cols in place. It is a positional
604        // local-col → global-col map per backend (consumed by
605        // `rows_triplets` / `take_backend_columns`); dropped cells map to
606        // `usize::MAX`, which both consumers skip. Already-`usize::MAX` entries
607        // (from a prior `mask_columns`) stay dropped — guard them so a second
608        // mask (e.g. up-front cell QC followed by a refine-pass mask) is
609        // re-entrant rather than indexing `old_to_new` out of bounds.
610        for cols in self.data_to_cols.values_mut() {
611            for c in cols.iter_mut() {
612                *c = if *c == usize::MAX {
613                    usize::MAX
614                } else {
615                    old_to_new[*c].unwrap_or(usize::MAX)
616                };
617            }
618        }
619
620        // Rebuild the Union-mode canonical-barcode → global lookup
621        // (empty / no-op under Disjoint).
622        if !self.col_name_position.is_empty() {
623            let mut pos: HashMap<Box<str>, u32> =
624                HashMap::with_capacity_and_hasher(next, Default::default());
625            for (g, name) in self.column_names_with_data_tag.iter().enumerate() {
626                let canon: Box<str> = match self.column_canonicalizer.as_ref() {
627                    Some(c) => c(name),
628                    None => name.clone(),
629                };
630                let g_u32: u32 = g
631                    .try_into()
632                    .map_err(|_| anyhow::anyhow!("global col overflows u32"))?;
633                pos.insert(canon, g_u32);
634            }
635            self.col_name_position = pos;
636        }
637
638        self.offset = next;
639        self.cached_num_columns = next;
640
641        // Any cell-indexed membership is now stale (asserted unset above).
642        self.derived = DerivedCaches::default();
643
644        log::info!(
645            "mask_columns: {} → {} cells ({} excluded)",
646            n,
647            next,
648            n - next
649        );
650        Ok(())
651    }
652
653    /// Drop the cell-indexed group/batch membership caches so the columns can
654    /// be re-masked and the membership re-registered from scratch. Needed
655    /// before [`Self::mask_columns`] when a collapse already registered groups
656    /// on this backend (it asserts the caches are unset); the next
657    /// `assign_groups` / `register_batch_membership` / collapse rebuilds them.
658    /// This is the fourth reset site alluded to in [`DerivedCaches`]. Column
659    /// multiplicities and scales are not membership and stay registered.
660    pub fn clear_column_membership(&mut self) {
661        self.derived = DerivedCaches::default();
662    }
663
664    pub fn row_names(&self) -> anyhow::Result<Vec<Box<str>>> {
665        let ntot = self.num_rows();
666        let mut ret = vec![Box::from(""); ntot];
667        for (raw_global, name) in self.row_names_by_global.iter().enumerate() {
668            if let Some(compact) = self
669                .global_to_compact_row
670                .get(raw_global)
671                .copied()
672                .flatten()
673            {
674                ret[compact] = name.clone();
675            }
676        }
677        Ok(ret)
678    }
679}