Skip to main content

data_beans/alg/collapse_data/
mod.rs

1#![allow(dead_code)]
2
3use crate::sparse_data_visitors::*;
4use crate::sparse_io_stack::SparseIoStack;
5use crate::sparse_io_vector::SparseIoVec;
6use legume_numeric::matrix::knn_match::ColumnDict;
7use legume_numeric::matrix::traits::*;
8use legume_numeric::param::dmatrix_gamma::*;
9use legume_numeric::param::traits::Inference;
10use legume_numeric::param::traits::*;
11use log::{info, warn};
12use nalgebra::DMatrix;
13use rayon::prelude::*;
14use std::ops::AddAssign;
15use std::sync::{Arc, Mutex};
16
17use crate::alg::random_projection::binary_sort_columns;
18
19use rustc_hash::FxHashMap as HashMap;
20type CscMat = nalgebra_sparse::CscMatrix<f32>;
21
22/// Sparse pb-sample gene profile: `rows[pbsamp] = Vec<(gene_idx, sum)>` sorted
23/// by `gene_idx`, as produced by `collect_pb_sample_gene_sums` and
24/// consumed by `collect_matched_stat_coarse` and the refinement pass.
25pub type GeneSums = Vec<Vec<(usize, f32)>>;
26
27pub const DEFAULT_KNN: usize = 10;
28pub const DEFAULT_OPT_ITER: usize = 100;
29
30/// Per-level collapse output plus the partition hierarchy across levels.
31///
32/// Returned by [`collapse_columns_multilevel_with_hierarchy`] for
33/// consumers (e.g. `graph-embedding-util`'s nested chain sampler) that
34/// need parent/child maps between pb-samples at adjacent levels.
35/// `cell_to_pb_per_level` is finest-first, parallel to `levels`:
36/// `cell_to_pb_per_level[k][c] = pb_id at level k for cell c`.
37mod pb_samples;
38use pb_samples::{
39    build_pb_sample_layout, build_pb_sample_to_cells, build_pb_samples,
40    collect_pb_sample_gene_sums, per_batch_sc_neighbors,
41};
42pub use pb_samples::{PbSampleCollection, PbSampleLayout};
43// Shared cross-batch pb-sample matching, reused by `refine_multilevel`.
44pub(crate) use pb_samples::bbknn_match_one_pbsamp;
45mod reassign_cells;
46pub use reassign_cells::ReassignCellsParams;
47mod pb_tree;
48pub use pb_tree::{BelowEdge, ContrastGene, PbTree, PbTreeParams, RootRecord, SplitRecord};
49mod refine;
50pub use refine::StackCollapseOut;
51use refine::{
52    compute_level_sort_dims, fine_to_coarse_from_refined, pad_numeric_labels,
53    refine_and_collect_single_layer, refine_and_collect_stack, split_anchored_finest_groups,
54    RefineCollectCtx,
55};
56mod stats;
57use stats::{
58    collect_basic_stat_visitor, collect_batch_stat_visitor, collect_matched_stat_coarse,
59    collect_matched_stat_visitor, merge_stat, optimize, KnnParams, DEFAULT_NUM_LEVELS,
60};
61pub use stats::{resample_and_optimize, CollapsedOut, CollapsedStat};
62mod strata;
63pub use strata::collapse_columns_multilevel_with_strata;
64
65pub struct MultilevelCollapseOut {
66    pub levels: Vec<CollapsedOut>,
67    pub cell_to_pb_per_level: Vec<Vec<usize>>,
68    /// The tree behind the finest partition, when it was grown (see
69    /// `MultilevelParams::pb_tree`).
70    pub pb_tree: Option<PbTree>,
71}
72
73/// Configuration for multi-level collapsing.
74#[derive(Clone)]
75pub struct MultilevelParams {
76    pub knn_pb_samples: usize,
77    pub num_levels: usize,
78    pub sort_dim: usize,
79    pub num_opt_iter: usize,
80    /// BBKNN + Poisson DC-SBM refinement on top of the hash partition.
81    /// Zero sweeps (`num_gibbs == 0 && num_greedy == 0`) keep the hash
82    /// partition as is.
83    pub refine: crate::alg::refine_multilevel::RefineParams,
84    /// Which posterior planes the *output* `CollapsedOut` should carry.
85    /// `MeanOnly` skips the sd / log_mean / log_sd allocations entirely —
86    /// a big memory win for consumers that only read `posterior_mean()`
87    /// (e.g. bge). Use `All` when the caller exports log-scale dictionaries.
88    pub output_calibration: legume_numeric::param::traits::CalibrateTarget,
89    /// Batch labels whose columns are ALREADY batch-corrected pseudobulks —
90    /// a prior run's carried reference. `Some` switches the cross-batch
91    /// counterfactual from pooled mutual adjustment to **greedy batch
92    /// correction**: every pb-sample's counterfactual is drawn from the
93    /// anchor batches only, so new batches are corrected *toward* the anchor
94    /// frame while an anchor pb-sample self-matches (its δ settles at the
95    /// prior ≈ 1 and is never re-adjusted). Anchored columns also skip
96    /// pb-sample re-merging — each stays its own matching unit, at the
97    /// resolution the prior run already paid for. `None` = pooled behavior,
98    /// bit-identical to before this field existed.
99    pub anchor_batches: Option<Vec<Box<str>>>,
100    /// Batch labels whose columns are ALREADY summaries but are **not** a
101    /// reference frame — bulk RNA-seq samples. Like anchors, each column
102    /// bypasses the partition entirely (its own singleton pb-sample and its
103    /// own singleton finest group — a bulk sample is never re-averaged), but
104    /// unlike anchors it takes no part in cross-batch matching in EITHER
105    /// direction: a bulk sample is a mixture over cell states, so matching it
106    /// to a single-state pb-sample (as source or receiver) would let δ absorb
107    /// composition rather than platform. Bulk therefore contributes observed
108    /// sums only — the dictionary sees it, δ/γ never do, and δ for a bulk
109    /// batch stays at its prior structurally. `None` = no bulk batches.
110    pub bulk_batches: Option<Vec<Box<str>>>,
111    /// Distinguish "unmeasured" from "measured as zero" when backends carry
112    /// different feature panels (`RowAlignment::Union`): per-gene denominators
113    /// count only the mass whose source measures the gene, and δ falls to its
114    /// prior where a batch has no panel coverage. A no-op (bitwise) when every
115    /// backend covers every row. Off for callers whose union is *intentional*
116    /// structural disjointness — multiome stacks modalities on one axis, and
117    /// re-weighting that is its own decision, not a side effect.
118    pub observe_panels: bool,
119    /// Retain the finest level's Gamma sufficient statistics even under
120    /// `MeanOnly` calibration. `--emit-pb-reference` serializes
121    /// `evidence_mean` — data sum over data denominator — which reads
122    /// `a_stat`/`b_stat`; `MeanOnly` normally drops them per block to bound
123    /// memory. Costs two extra `[D, S]` planes per finest-level parameter.
124    pub keep_finest_stats: bool,
125    /// Grow the finest partition as a tree from the marginal top nodes:
126    /// reassign cells by likelihood, then bisect on residual components to
127    /// one leaf target per level. `None` keeps the marginal hash, bit-identical
128    /// to before this field existed.
129    pub pb_tree: Option<PbTreeParams>,
130    /// Per-cell CNV stratum (`0` = mixable residual). Crossed into finest
131    /// codes so no pb-group mixes strata; BBKNN matches only within stratum;
132    /// unmatched clone mass is excluded from the δ update so private CN
133    /// stays in `mu_adjusted`. `None` is bit-identical to the pre-strata path.
134    pub strata: Option<Vec<usize>>,
135}
136
137impl MultilevelParams {
138    pub fn new(proj_dim: usize) -> Self {
139        Self {
140            knn_pb_samples: DEFAULT_KNN,
141            num_levels: DEFAULT_NUM_LEVELS,
142            sort_dim: proj_dim.min(12),
143            num_opt_iter: DEFAULT_OPT_ITER,
144            refine: crate::alg::refine_multilevel::RefineParams::default(),
145            output_calibration: legume_numeric::param::traits::CalibrateTarget::All,
146            anchor_batches: None,
147            bulk_batches: None,
148            observe_panels: true,
149            keep_finest_stats: false,
150            pb_tree: None,
151            strata: None,
152        }
153    }
154
155    /// Copy with [`Self::strata`] set (used by the thin `with_strata` wrapper).
156    #[must_use]
157    pub fn with_strata(&self, strata: Vec<usize>) -> Self {
158        let mut p = self.clone();
159        p.strata = Some(strata);
160        p
161    }
162}
163
164/// Bits needed to encode stratum labels in the low bits of a hash code.
165/// A single occupied stratum needs no bits (identity with the unstratified
166/// path); otherwise `ceil(log2(max_label + 1))`.
167fn stratum_bits(strata: &[usize]) -> usize {
168    let mut occupied: Vec<usize> = strata.to_vec();
169    occupied.sort_unstable();
170    occupied.dedup();
171    if occupied.len() <= 1 {
172        return 0;
173    }
174    let max = *occupied.last().unwrap_or(&0);
175    let n = max + 1;
176    (usize::BITS as usize - n.saturating_sub(1).leading_zeros() as usize).max(1)
177}
178
179/// Cross finest hash/tree codes with per-cell strata so no group mixes
180/// strata. Stratum occupies the **low** bits. Level widths are **not**
181/// bumped — expression budget (`1 << d` tree targets, refine bounds) stays
182/// unchanged; hash/reproject masks add `s_bits` separately.
183fn apply_strata_to_codes(
184    codes: &[usize],
185    level_dims: &[usize],
186    strata: &[usize],
187) -> anyhow::Result<(Vec<usize>, Vec<usize>, usize)> {
188    anyhow::ensure!(
189        codes.len() == strata.len(),
190        "strata has {} entries, codes have {}",
191        strata.len(),
192        codes.len()
193    );
194    let s_bits = stratum_bits(strata);
195    if s_bits == 0 {
196        return Ok((codes.to_vec(), level_dims.to_vec(), 0));
197    }
198    let stratified: Vec<usize> = codes
199        .iter()
200        .zip(strata.iter())
201        .map(|(&c, &s)| (c << s_bits) | s)
202        .collect();
203    Ok((stratified, level_dims.to_vec(), s_bits))
204}
205
206/// Finest cell codes plus what the per-level grouping needs to read them.
207struct FinestCodes {
208    /// Per-cell finest code (stratum bits in the low `strata_bits`).
209    codes: Vec<usize>,
210    /// Per-level expression widths, finest-first — un-bumped by strata.
211    widths: Vec<usize>,
212    /// The tree behind the finest partition, when one was grown.
213    tree: Option<PbTree>,
214    /// Width of the stratum field crossed into `codes` (`0` = no strata).
215    strata_bits: usize,
216}
217
218/// Finest codes and their level widths (finest-first). Without residual
219/// bits: the marginal sketch signs masked by `level_dims`. With them: the
220/// marginal top nodes (optionally re-sorted by `reassign_cells`) are the
221/// roots of a tree grown to one leaf target per level, `2^dim` for each of
222/// `level_dims`, and the nested levels are packed into prefix codes whose
223/// widths replace `level_dims`. Batch membership must already be
224/// registered on `data_vec`.
225///
226/// When [`MultilevelParams::strata`] is set, stratum bits are crossed into
227/// the codes after the tree/hash is grown (so leaf budgets stay expression-
228/// only). Returned widths are the un-bumped expression dims; `strata_bits`
229/// is carried separately for hash/reproject masks.
230fn finest_codes(
231    data_vec: &SparseIoVec,
232    proj_kn: &DMatrix<f32>,
233    level_dims: &[usize],
234    params: &MultilevelParams,
235) -> anyhow::Result<FinestCodes> {
236    let finest_dim = level_dims[0];
237    let nn = proj_kn.ncols();
238    let kk = proj_kn.nrows().min(finest_dim).min(nn);
239    let codes = binary_sort_columns(proj_kn, kk)?;
240    let (codes, widths, tree) = match params.pb_tree.as_ref() {
241        None => (codes, level_dims.to_vec(), None),
242        Some(rb) => {
243            let coarse_bits = if level_dims.len() >= 2 {
244                *level_dims.last().expect("non-empty level dims")
245            } else {
246                stats::DEFAULT_COARSEST_SORT_DIM.min(kk)
247            };
248            let low_mask = (1usize << coarse_bits) - 1;
249            let n = data_vec.num_columns();
250            // Summary columns (carried reference, bulk) are not tree members; they
251            // become singleton pb-samples downstream regardless of their code.
252            let active: Vec<bool> = if data_vec.has_column_multiplicity() {
253                (0..n)
254                    .map(|c| (data_vec.column_multiplicity(c) - 1.0).abs() <= f32::EPSILON)
255                    .collect()
256            } else {
257                vec![true; n]
258            };
259            let low: Vec<usize> = codes.iter().map(|&c| c & low_mask).collect();
260            let (mut node, _) = crate::alg::dc_poisson::compact_labels(&low);
261            // One pass over the store serves both the reassignment and the
262            // tree; per-node reads would decode every chunk once per node.
263            let csc = data_vec.read_columns_csc(0..n)?;
264            let reassigned_cells = match rb.reassign_cells.as_ref() {
265                Some(cr) => {
266                    let col_to_batch = data_vec.get_batch_membership(0..n);
267                    reassign_cells::reassign_cells_to_nodes(
268                        &csc,
269                        &col_to_batch,
270                        data_vec.num_batches().max(1),
271                        &active,
272                        &mut node,
273                        cr,
274                    )
275                }
276                None => 0,
277            };
278            for (c, nd) in node.iter_mut().enumerate() {
279                if !active[c] {
280                    *nd = usize::MAX;
281                }
282            }
283            // Leaf targets per level, coarse to fine — expression budget only.
284            let targets: Vec<usize> = level_dims.iter().rev().map(|&d| 1usize << d).collect();
285            let (codes, widths, mut tree) =
286                pb_tree::build_tree(data_vec, &csc, &node, &codes, &targets, rb)?;
287            tree.reassigned_cells = reassigned_cells;
288            (codes, widths, Some(tree))
289        }
290    };
291    maybe_stratify_codes(codes, widths, tree, params)
292}
293
294/// Apply [`MultilevelParams::strata`] to finest codes, or pass through.
295fn maybe_stratify_codes(
296    codes: Vec<usize>,
297    widths: Vec<usize>,
298    tree: Option<PbTree>,
299    params: &MultilevelParams,
300) -> anyhow::Result<FinestCodes> {
301    let Some(strata) = params.strata.as_deref() else {
302        return Ok(FinestCodes {
303            codes,
304            widths,
305            tree,
306            strata_bits: 0,
307        });
308    };
309    anyhow::ensure!(
310        strata.len() == codes.len(),
311        "MultilevelParams.strata has {} entries, codes have {}",
312        strata.len(),
313        codes.len()
314    );
315    let (codes, widths, s_bits) = apply_strata_to_codes(&codes, &widths, strata)?;
316    let n_occ = {
317        let mut u = strata.to_vec();
318        u.sort_unstable();
319        u.dedup();
320        u.len()
321    };
322    info!(
323        "CNV strata: crossed finest codes with {} cell strata ({} occupied, {} stratum bits)",
324        strata.len(),
325        n_occ,
326        s_bits
327    );
328    Ok(FinestCodes {
329        codes,
330        widths,
331        tree,
332        strata_bits: s_bits,
333    })
334}
335
336/// Resolve [`MultilevelParams::anchor_batches`] / `bulk_batches` names to
337/// batch indices.
338///
339/// A named batch that does not exist is an error, not a shrug: silently
340/// dropping it would quietly fall back to pooled mutual adjustment — the
341/// exact behavior anchoring (and the bulk exclusion) exists to prevent.
342fn resolve_named_batches(
343    data_vec: &SparseIoVec,
344    role: &str,
345    names: Option<&[Box<str>]>,
346) -> anyhow::Result<Option<Vec<usize>>> {
347    let Some(names) = names else { return Ok(None) };
348    let map = data_vec
349        .batch_name_map()
350        .ok_or_else(|| anyhow::anyhow!("{role} batches given but no batches are registered"))?;
351    let mut idx = Vec::with_capacity(names.len());
352    for n in names {
353        let Some(&b) = map.get(n) else {
354            anyhow::bail!(
355                "{role} batch `{n}` is not among the registered batches ({:?})",
356                map.keys().collect::<Vec<_>>(),
357            );
358        };
359        idx.push(b);
360    }
361    Ok(Some(idx))
362}
363
364/// A batch cannot be both an anchor (a counterfactual source) and bulk
365/// (barred from matching): the two roles contradict each other, and which
366/// one silently won would decide whether composition leaks into δ.
367/// Greedy default: when bulk batches are named and no anchor is given, every
368/// NON-bulk batch becomes the anchor frame.
369///
370/// This is the same discipline `senna update` applies to a carried
371/// pb_reference — only the new samples are adjusted, the established frame
372/// stays fixed. Concretely it makes bulk draw its counterfactual from the
373/// cells and be corrected toward them, while the cells self-match (their δ
374/// settles at the prior) and bulk, never being in the anchor set, cannot
375/// serve as anyone's counterfactual. Pooled mutual adjustment — where the
376/// cell frame drifts toward bulk — is what this avoids.
377fn greedy_anchor_for_bulk(
378    data_vec: &SparseIoVec,
379    anchors: Option<Vec<usize>>,
380    bulk: Option<&[usize]>,
381) -> Option<Vec<usize>> {
382    match (anchors, bulk) {
383        (Some(a), _) => Some(a),
384        (None, Some(b)) if !b.is_empty() => {
385            let all = data_vec.num_batches();
386            let frame: Vec<usize> = (0..all).filter(|i| !b.contains(i)).collect();
387            (!frame.is_empty()).then(|| {
388                info!(
389                    "Greedy bulk correction: {} bulk batch(es) corrected toward {} cell batch(es); \
390                     the cell frame is anchored and does not move",
391                    b.len(),
392                    frame.len()
393                );
394                frame
395            })
396        }
397        (None, _) => None,
398    }
399}
400
401fn ensure_disjoint_roles(anchors: Option<&[usize]>, bulk: Option<&[usize]>) -> anyhow::Result<()> {
402    if let (Some(a), Some(b)) = (anchors, bulk) {
403        if let Some(shared) = a.iter().find(|x| b.contains(x)) {
404            anyhow::bail!(
405                "batch index {shared} is named as both an anchor and a bulk batch; \
406                 the roles are mutually exclusive"
407            );
408        }
409    }
410    Ok(())
411}
412
413/// Attach per-panel observability to a freshly collected fine-level stat.
414///
415/// `size_ds[g, s] = Σ_{sources b measuring g} count[b, s]` where `count[b, s]`
416/// is the multiplicity-weighted mass batch/backends contribute to sample `s` —
417/// the factorized form, so the cost is `O(D·S·B + N)`, never `O(D·N)`. The
418/// per-(gene, batch) δ mask is 1 where any source in the batch measures the
419/// gene. Uses whatever group assignment currently stands on `data_vec`, i.e.
420/// exactly the one that filled the stat.
421///
422/// Leaves the stat untouched (the bitwise-historical path) when every backend
423/// covers every row, or when columns merge several backends (column-union /
424/// multiome), whose panel semantics are deliberate.
425fn attach_observability(stat: &mut CollapsedStat, data_vec: &SparseIoVec) -> anyhow::Result<()> {
426    let Some(coverage) = data_vec.row_coverage_by_backend() else {
427        return Ok(());
428    };
429    let ncols = data_vec.num_columns();
430    let mut sources = Vec::with_capacity(ncols);
431    for c in 0..ncols {
432        match data_vec.column_source(c) {
433            Some(b) => sources.push(b),
434            None => {
435                warn!(
436                    "panel observability skipped: column {c} merges several backends \
437                     (column-union alignment)"
438                );
439                return Ok(());
440            }
441        }
442    }
443
444    let num_genes = stat.num_genes();
445    let num_samples = stat.num_samples();
446    let num_sources = data_vec.len();
447    let cell_to_group = data_vec.get_group_membership(0..ncols)?;
448
449    // Multiplicity-weighted mass per (source, sample) and per-batch source use.
450    let mut count_bs = DMatrix::<f32>::zeros(num_sources, num_samples);
451    let batch_of = data_vec.get_batch_membership(0..ncols);
452    let num_batches = stat.num_batches();
453    let mut source_in_batch = vec![vec![false; num_batches]; num_sources];
454    for c in 0..ncols {
455        let s = cell_to_group[c];
456        if s < num_samples {
457            count_bs[(sources[c], s)] += data_vec.column_multiplicity(c);
458        }
459        if let Some(&b) = batch_of.get(c) {
460            if b < num_batches {
461                source_in_batch[sources[c]][b] = true;
462            }
463        }
464    }
465
466    let mut size_ds = DMatrix::<f32>::zeros(num_genes, num_samples);
467    for (src, cov) in coverage.iter().enumerate() {
468        anyhow::ensure!(
469            cov.len() == num_genes,
470            "row coverage has {} rows but the stat has {num_genes} genes",
471            cov.len(),
472        );
473        for (g, &covered) in cov.iter().enumerate() {
474            if covered {
475                for s in 0..num_samples {
476                    size_ds[(g, s)] += count_bs[(src, s)];
477                }
478            }
479        }
480    }
481
482    let mut mask_db = DMatrix::<f32>::zeros(num_genes, num_batches);
483    for (src, cov) in coverage.iter().enumerate() {
484        for (b, &used) in source_in_batch[src].iter().enumerate() {
485            if used {
486                for (g, &covered) in cov.iter().enumerate() {
487                    if covered {
488                        mask_db[(g, b)] = 1.0;
489                    }
490                }
491            }
492        }
493    }
494
495    info!(
496        "panel observability attached: {} of {} (gene, sample) entries below full size",
497        size_ds
498            .column_iter()
499            .enumerate()
500            .map(|(s, col)| col.iter().filter(|&&v| v < stat.size_s[s]).count())
501            .sum::<usize>(),
502        num_genes * num_samples,
503    );
504    stat.size_ds = Some(size_ds);
505    stat.obs_mask_db = (mask_db.iter().any(|&v| v == 0.0)).then_some(mask_db);
506    Ok(())
507}
508
509pub struct EmptyArg {}
510
511#[cfg(debug_assertions)]
512use log::debug;
513
514/// Given a feature/projection matrix (factor x cells), we assign each
515/// cell to a sample and return pseudobulk (collapsed) matrices
516///
517/// (1) Register batches if needed (2) collapse columns/cells into samples
518///
519pub trait CollapsingOps {
520    ///
521    /// Collapse columns/cells into samples as allocated by
522    /// `assign_columns_to_samples`
523    ///
524    /// # Arguments
525    /// * `cells_per_group` - number of cells per sample (None: no down sampling)
526    /// * `knn_batches` - number of nearest neighbour batches
527    /// * `knn_cells` - number of nearest neighbors for building HNSW (default: 10)
528    /// * `reference` - reference batch for counterfactual inference
529    /// * `num_opt_iter` - number of optimization iterations (default: 100)
530    ///
531    fn collapse_columns(
532        &self,
533        knn_batches: Option<usize>,
534        knn_cells: Option<usize>,
535        reference_batch_names: Option<&[Box<str>]>,
536        num_opt_iter: Option<usize>,
537    ) -> anyhow::Result<CollapsedOut>;
538
539    /// Register batch information and build a `HnswMap` object for
540    /// each batch for fast nearest neighbor search within each batch
541    /// and store them in the `SparseIoVec`
542    ///
543    /// # Arguments
544    /// * `proj_kn` - random projection matrix
545    /// * `col_to_batch` - map: cell -> batch
546    fn build_hnsw_per_batch<T>(
547        &mut self,
548        proj_kn: &nalgebra::DMatrix<f32>,
549        col_to_batch: &[T],
550    ) -> anyhow::Result<()>
551    where
552        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString;
553
554    fn collect_basic_stat(&self, stat: &mut CollapsedStat) -> anyhow::Result<()>;
555
556    fn collect_batch_stat(&self, stat: &mut CollapsedStat) -> anyhow::Result<()>;
557
558    fn collect_matched_stat(
559        &self,
560        knn_batches: usize,
561        knn_cols: usize,
562        reference_indices: Option<&[usize]>,
563        stat: &mut CollapsedStat,
564    ) -> anyhow::Result<()>;
565}
566
567impl CollapsingOps for SparseIoVec {
568    fn build_hnsw_per_batch<T>(
569        &mut self,
570        proj_kn: &nalgebra::DMatrix<f32>,
571        col_to_batch: &[T],
572    ) -> anyhow::Result<()>
573    where
574        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
575    {
576        info!("creating batch-specific HNSW maps ...");
577        self.register_batches_dmatrix(proj_kn, col_to_batch)?;
578
579        info!(
580            "found {} columns across {} batches",
581            self.num_columns(),
582            self.num_batches()
583        );
584
585        Ok(())
586    }
587
588    fn collapse_columns(
589        &self,
590        knn_batches: Option<usize>,
591        knn_cells: Option<usize>,
592        reference_batch_names: Option<&[Box<str>]>,
593        num_opt_iter: Option<usize>,
594    ) -> anyhow::Result<CollapsedOut> {
595        let group_to_cols = self.take_grouped_columns().ok_or(anyhow::anyhow!(
596            "The columns were not assigned before. Call `assign_columns_to_groups`"
597        ))?;
598
599        let num_features = self.num_rows();
600        let num_groups = group_to_cols.len();
601        let num_batches = self.num_batches();
602
603        let mut stat = CollapsedStat::new(num_features, num_groups, num_batches);
604        info!("basic statistics across {} groups", num_groups);
605        self.collect_basic_stat(&mut stat)?;
606
607        if num_batches > 1 {
608            info!(
609                "batch-specific statistics across {} batches over {} samples",
610                num_batches, num_groups
611            );
612
613            let batch_name_map = self
614                .batch_name_map()
615                .ok_or(anyhow::anyhow!("unable to read batch names"))?;
616
617            let reference_indices = reference_batch_names.map(|x| {
618                x.iter()
619                    .filter_map(|b| batch_name_map.get(b))
620                    .copied()
621                    .collect::<Vec<_>>()
622            });
623
624            if let Some(r) = reference_indices.as_ref() {
625                if r.is_empty() {
626                    let ref_names = reference_batch_names
627                        .unwrap()
628                        .iter()
629                        .map(|x| x.to_string())
630                        .collect::<Vec<_>>()
631                        .join(",");
632
633                    let bat_names = self
634                        .batch_names()
635                        .unwrap()
636                        .iter()
637                        .map(|x| x.to_string())
638                        .collect::<Vec<_>>()
639                        .join(",");
640
641                    warn!("{} vs. {}", ref_names, bat_names);
642
643                    return Err(anyhow::anyhow!("no reference batch names matched!"));
644                }
645            }
646
647            self.collect_batch_stat(&mut stat)?;
648
649            info!(
650                "counterfactual inference across {} batches over {} samples",
651                num_batches, num_groups,
652            );
653
654            let knn_batches = knn_batches.unwrap_or(2);
655            let knn_cells = knn_cells.unwrap_or(DEFAULT_KNN);
656
657            self.collect_matched_stat(
658                knn_batches,
659                knn_cells,
660                reference_indices.as_deref(),
661                &mut stat,
662            )?;
663        } // if num_batches > 1
664
665        /////////////////////////////
666        // Resolve mean parameters //
667        /////////////////////////////
668
669        info!("optimizing the collapsed parameters...");
670        let (a0, b0) = (1_f32, 1_f32);
671        optimize(
672            &stat,
673            (a0, b0),
674            num_opt_iter.unwrap_or(DEFAULT_OPT_ITER),
675            "Optimizing",
676            CalibrateTarget::All,
677            false,
678        )
679    }
680
681    fn collect_basic_stat(&self, stat: &mut CollapsedStat) -> anyhow::Result<()> {
682        self.visit_columns_by_group(&collect_basic_stat_visitor, &EmptyArg {}, stat)
683    }
684
685    fn collect_batch_stat(&self, stat: &mut CollapsedStat) -> anyhow::Result<()> {
686        self.visit_columns_by_group(&collect_batch_stat_visitor, &EmptyArg {}, stat)
687    }
688
689    fn collect_matched_stat(
690        &self,
691        knn_batches: usize,
692        knn_cells: usize,
693        reference_indices: Option<&[usize]>,
694        stat: &mut CollapsedStat,
695    ) -> anyhow::Result<()> {
696        // The reference batches source the counterfactual and pin δ.
697        stat.anchor_batches = reference_indices.map(<[usize]>::to_vec).unwrap_or_default();
698        self.visit_columns_by_group(
699            &collect_matched_stat_visitor,
700            &KnnParams {
701                knn_batches,
702                knn_cells,
703                reference_indices,
704            },
705            stat,
706        )
707    }
708}
709
710pub trait MultilevelCollapsingOps {
711    type LevelOutput;
712
713    fn collapse_columns_multilevel<T>(
714        &mut self,
715        proj_kn: &DMatrix<f32>,
716        batch_membership: &[T],
717        params: &MultilevelParams,
718    ) -> anyhow::Result<Self::LevelOutput>
719    where
720        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString;
721
722    fn collapse_columns_multilevel_vec<T>(
723        &mut self,
724        proj_kn: &DMatrix<f32>,
725        batch_membership: &[T],
726        params: &MultilevelParams,
727    ) -> anyhow::Result<Vec<Self::LevelOutput>>
728    where
729        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString;
730}
731
732/// Same as `SparseIoVec::collapse_columns_multilevel_vec`, but also returns
733/// the per-level cell → pb mapping needed for hierarchical / nested
734/// chain sampling in downstream consumers (currently
735/// `graph-embedding-util`).
736pub fn collapse_columns_multilevel_with_hierarchy<T>(
737    data_vec: &mut SparseIoVec,
738    proj_kn: &DMatrix<f32>,
739    batch_membership: &[T],
740    params: &MultilevelParams,
741) -> anyhow::Result<MultilevelCollapseOut>
742where
743    T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
744{
745    let sort_dim = params.sort_dim;
746    let knn = params.knn_pb_samples;
747    let opt_iter = params.num_opt_iter;
748
749    data_vec.register_batch_membership(batch_membership);
750    let num_features = data_vec.num_rows();
751    let num_batches = data_vec.num_batches();
752    if num_batches >= 2 {
753        data_vec.build_hnsw_per_batch(proj_kn, batch_membership)?;
754    }
755
756    let level_dims = compute_level_sort_dims(sort_dim, params.num_levels);
757    let FinestCodes {
758        codes: fine_codes,
759        widths: level_dims,
760        tree: pb_tree,
761        strata_bits,
762    } = finest_codes(data_vec, proj_kn, &level_dims, params)?;
763    data_vec.assign_groups(&fine_codes, None);
764
765    let group_to_cols = data_vec
766        .take_grouped_columns()
767        .ok_or_else(|| anyhow::anyhow!("columns not assigned"))?
768        .clone();
769
770    let refine_params = &params.refine;
771
772    let anchor_batches =
773        resolve_named_batches(data_vec, "anchor", params.anchor_batches.as_deref())?;
774    let bulk_batches = resolve_named_batches(data_vec, "bulk", params.bulk_batches.as_deref())?;
775    ensure_disjoint_roles(anchor_batches.as_deref(), bulk_batches.as_deref())?;
776    // `summary_batches` are the batches whose columns are ALREADY summaries
777    // (a carried pb_reference, or bulk) and so must never be re-averaged.
778    // `anchor_batches` below is the MATCHING frame, which after the greedy
779    // default is the cells — and cells must still collapse into pseudobulks.
780    // Conflating the two turns every cell into its own pb-sample.
781    let summary_batches = anchor_batches.clone();
782    let anchor_batches = greedy_anchor_for_bulk(data_vec, anchor_batches, bulk_batches.as_deref());
783    let ctx = RefineCollectCtx {
784        fine_codes: &fine_codes,
785        group_to_cols_finest: &group_to_cols,
786        level_dims: &level_dims,
787        num_features,
788        num_batches,
789        knn,
790        opt_iter,
791        refine_params,
792        output_calibration: params.output_calibration,
793        anchor_batches: anchor_batches.as_deref(),
794        summary_batches: summary_batches.as_deref(),
795        bulk_batches: bulk_batches.as_deref(),
796        observe_panels: params.observe_panels,
797        keep_finest_stats: params.keep_finest_stats,
798        pb_tree: pb_tree.as_ref(),
799        cell_to_stratum: params.strata.as_deref(),
800        exclude_unmatched_from_delta: params.strata.is_some(),
801        strata_bits,
802    };
803    refine_and_collect_single_layer(data_vec, proj_kn, &ctx)
804}
805
806/// Variant of [`collapse_columns_multilevel_with_hierarchy`] that **skips
807/// the BBKNN + Poisson DC-SBM refinement**, instead synthesising the
808/// per-pb-sample group assignment from a caller-supplied
809/// `cell_to_pb_per_level` (finest-first; the same shape returned in
810/// [`MultilevelCollapseOut.cell_to_pb_per_level`] by the refining
811/// entry point). Typical use: `senna {topic, itopic, ce-topic} --from`
812/// inheriting a prior run's partition.
813///
814/// Each level's pb-sample → group label is decided by majority vote
815/// across the cells in that pb-sample. The downstream `optimize` step
816/// still runs per level, so the returned `CollapsedOut` posteriors
817/// reflect this run's batch model and priors — only the expensive
818/// clustering work is bypassed.
819pub fn collapse_columns_multilevel_with_partition<T>(
820    data_vec: &mut SparseIoVec,
821    proj_kn: &DMatrix<f32>,
822    batch_membership: &[T],
823    params: &MultilevelParams,
824    cell_to_pb_per_level: &[Vec<usize>],
825) -> anyhow::Result<MultilevelCollapseOut>
826where
827    T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
828{
829    let knn = params.knn_pb_samples;
830    let opt_iter = params.num_opt_iter;
831
832    data_vec.register_batch_membership(batch_membership);
833    let num_features = data_vec.num_rows();
834    let num_batches = data_vec.num_batches();
835    if num_batches >= 2 {
836        data_vec.build_hnsw_per_batch(proj_kn, batch_membership)?;
837    }
838
839    let level_dims = compute_level_sort_dims(params.sort_dim, params.num_levels);
840    anyhow::ensure!(
841        cell_to_pb_per_level.len() == level_dims.len(),
842        "inherited cell_to_pb has {} levels but --num-levels is {}; \
843         pass --num-levels to match the source run",
844        cell_to_pb_per_level.len(),
845        level_dims.len(),
846    );
847    // pb-samples are built from the INHERITED finest membership, so each
848    // pb-sample is one (batch, source finest group) intersection and the
849    // per-level vote below is unanimous by construction. Fresh marginal
850    // leaves would straddle a source partition whose high bits came from
851    // within-node residuals, and a modal vote over them would erode it.
852    // pb-samples are still needed for the cross-batch matched-stat path on
853    // multi-batch data; refinement is what we skip.
854    let inherited_finest = &cell_to_pb_per_level[0];
855    anyhow::ensure!(
856        inherited_finest.len() == proj_kn.ncols(),
857        "inherited cell_to_pb finest level has {} cells, data has {}",
858        inherited_finest.len(),
859        proj_kn.ncols()
860    );
861    let k_inherited = inherited_finest.iter().copied().max().map_or(0, |m| m + 1);
862    data_vec.assign_groups(&pad_numeric_labels(inherited_finest, k_inherited), None);
863
864    let anchor_batches =
865        resolve_named_batches(data_vec, "anchor", params.anchor_batches.as_deref())?;
866    let bulk_batches = resolve_named_batches(data_vec, "bulk", params.bulk_batches.as_deref())?;
867    ensure_disjoint_roles(anchor_batches.as_deref(), bulk_batches.as_deref())?;
868    // `summary_batches` are the batches whose columns are ALREADY summaries
869    // (a carried pb_reference, or bulk) and so must never be re-averaged.
870    // `anchor_batches` below is the MATCHING frame, which after the greedy
871    // default is the cells — and cells must still collapse into pseudobulks.
872    // Conflating the two turns every cell into its own pb-sample.
873    let summary_batches = anchor_batches.clone();
874    let anchor_batches = greedy_anchor_for_bulk(data_vec, anchor_batches, bulk_batches.as_deref());
875    let pb_samples = build_pb_samples(
876        data_vec,
877        proj_kn,
878        num_features,
879        summary_batches.as_deref().unwrap_or(&[]),
880        bulk_batches.as_deref().unwrap_or(&[]),
881        params.strata.as_deref(),
882    )?;
883    let num_pb = pb_samples.layout.cell_counts.len();
884    let ncols = proj_kn.ncols();
885    let pb_sample_to_cells = build_pb_sample_to_cells(&pb_samples.layout);
886
887    // Synthesize a RefinedAssignment from the inherited cell→pb
888    // membership per pb-sample at each level. The vote is unanimous at the
889    // finest level by construction; coarser levels vote in case the source
890    // hierarchy was not strictly nested.
891    let num_levels = level_dims.len();
892    let mut pbsamp_to_group: Vec<Vec<usize>> = Vec::with_capacity(num_levels);
893    let mut num_groups_per_level: Vec<usize> = Vec::with_capacity(num_levels);
894    for (lvl_idx, lvl) in cell_to_pb_per_level.iter().enumerate() {
895        anyhow::ensure!(
896            lvl.len() == ncols,
897            "inherited cell_to_pb level {} has {} cells, data has {}",
898            lvl_idx,
899            lvl.len(),
900            ncols
901        );
902        let mut p2g: Vec<usize> = Vec::with_capacity(num_pb);
903        for cells in &pb_sample_to_cells {
904            p2g.push(modal_group(cells, lvl));
905        }
906        let (compact, k) = crate::alg::refine_multilevel::compact_labels(&p2g);
907        num_groups_per_level.push(k);
908        pbsamp_to_group.push(compact);
909    }
910    let refined = split_anchored_finest_groups(
911        crate::alg::refine_multilevel::RefinedAssignment {
912            pbsamp_to_group,
913            num_groups_per_level,
914        },
915        &pb_samples.layout,
916    );
917
918    info!(
919        "Inherited partition: {} cells, {} pb-samples, finest k={} (skipped BBKNN + DC-SBM refinement)",
920        ncols, num_pb, refined.num_groups_per_level[0]
921    );
922
923    // From here the path matches refine_and_collect_single_layer's
924    // post-refinement tail: assign groups, collect stats, fit Gamma
925    // posteriors level-by-level via merge_stat.
926    let k_finest = refined.num_groups_per_level[0];
927    let mut cell_to_group_finest = vec![0usize; ncols];
928    for (pbsamp, cells) in pb_sample_to_cells.iter().enumerate() {
929        let g = refined.pbsamp_to_group[0][pbsamp];
930        for &c in cells {
931            cell_to_group_finest[c] = g;
932        }
933    }
934    let finest_str = pad_numeric_labels(&cell_to_group_finest, k_finest);
935    data_vec.assign_groups(&finest_str, None);
936    debug_assert_eq!(data_vec.num_groups(), k_finest);
937
938    let mut fine_stat = CollapsedStat::new(num_features, k_finest, num_batches);
939    fine_stat.exclude_unmatched_from_delta = params.strata.is_some();
940    info!("Collecting basic stats over {} groups ...", k_finest);
941    data_vec.collect_basic_stat(&mut fine_stat)?;
942    if num_batches >= 2 {
943        info!(
944            "Collecting per-batch stats over {} groups × {} batches ...",
945            k_finest, num_batches
946        );
947        data_vec.collect_batch_stat(&mut fine_stat)?;
948        let batch_knn = data_vec
949            .batch_knn_lookup()
950            .ok_or_else(|| anyhow::anyhow!("batch_knn_lookup not built"))?;
951        info!(
952            "Collecting cross-batch matched stats (knn={}) over {} pb-samples ...",
953            knn, num_pb
954        );
955        collect_matched_stat_coarse(
956            &pb_samples.layout,
957            &pb_samples.gene_sums,
958            &refined.pbsamp_to_group[0],
959            batch_knn.as_slice(),
960            knn,
961            anchor_batches.as_deref(),
962            &mut fine_stat,
963        )?;
964    }
965    if params.observe_panels {
966        attach_observability(&mut fine_stat, data_vec)?;
967    }
968
969    let mut results: Vec<CollapsedOut> = Vec::with_capacity(num_levels);
970    info!(
971        "Level 1/{}: inherited k={} (finest; {} cells)",
972        num_levels, k_finest, ncols
973    );
974    let finest_out = optimize(
975        &fine_stat,
976        (1.0, 1.0),
977        opt_iter,
978        &format!("Inherit L1/{}", num_levels),
979        CalibrateTarget::All,
980        false,
981    )?;
982    results.push(finest_out);
983
984    let mut prev_stat = fine_stat;
985    for level in 1..num_levels {
986        let k_prev = refined.num_groups_per_level[level - 1];
987        let k_level = refined.num_groups_per_level[level];
988        let fine_to_coarse = fine_to_coarse_from_refined(
989            &refined.pbsamp_to_group[level - 1],
990            &refined.pbsamp_to_group[level],
991            k_prev,
992        );
993        let coarse_stat = merge_stat(&prev_stat, &fine_to_coarse, k_level);
994        info!(
995            "Level {}/{}: inherited k={} (merged from {})",
996            level + 1,
997            num_levels,
998            k_level,
999            k_prev
1000        );
1001        let level_opt_iter = (opt_iter / 2).max(10);
1002        let out = optimize(
1003            &coarse_stat,
1004            (1.0, 1.0),
1005            level_opt_iter,
1006            &format!("Inherit L{}/{}", level + 1, num_levels),
1007            CalibrateTarget::All,
1008            false,
1009        )?;
1010        results.push(out);
1011        prev_stat = coarse_stat;
1012    }
1013    info!(
1014        "Fitted pseudobulk posteriors for {} inherited levels: k = {:?} (finest first)",
1015        num_levels, refined.num_groups_per_level
1016    );
1017
1018    // Re-derive cell_to_pb_per_level from the post-modal-vote groups so
1019    // the returned struct is self-consistent — small drift vs the
1020    // inherited values is expected when pb-samples spanned multiple
1021    // source groups (handled by majority).
1022    let mut cell_to_pb_per_level_out: Vec<Vec<usize>> = Vec::with_capacity(num_levels);
1023    for level in 0..num_levels {
1024        let mut c2g = vec![0usize; ncols];
1025        for (pbsamp, cells) in pb_sample_to_cells.iter().enumerate() {
1026            let g = refined.pbsamp_to_group[level][pbsamp];
1027            for &c in cells {
1028                c2g[c] = g;
1029            }
1030        }
1031        cell_to_pb_per_level_out.push(c2g);
1032    }
1033
1034    Ok(MultilevelCollapseOut {
1035        levels: results,
1036        cell_to_pb_per_level: cell_to_pb_per_level_out,
1037        pb_tree: None,
1038    })
1039}
1040
1041/// Modal `lvl[c]` over `c ∈ cells`. Returns 0 when `cells` is empty.
1042/// Fast small-Vec path for the common case where pb-samples contain
1043/// just a handful of cells.
1044fn modal_group(cells: &[usize], lvl: &[usize]) -> usize {
1045    match cells {
1046        [] => 0,
1047        [c] => lvl[*c],
1048        _ => {
1049            use rustc_hash::FxHashMap;
1050            let mut counts: FxHashMap<usize, usize> = FxHashMap::default();
1051            for &c in cells {
1052                *counts.entry(lvl[c]).or_insert(0) += 1;
1053            }
1054            counts
1055                .into_iter()
1056                .max_by_key(|&(_, n)| n)
1057                .map(|(g, _)| g)
1058                .unwrap_or(0)
1059        }
1060    }
1061}
1062
1063impl MultilevelCollapsingOps for SparseIoVec {
1064    type LevelOutput = CollapsedOut;
1065
1066    fn collapse_columns_multilevel<T>(
1067        &mut self,
1068        proj_kn: &DMatrix<f32>,
1069        batch_membership: &[T],
1070        params: &MultilevelParams,
1071    ) -> anyhow::Result<CollapsedOut>
1072    where
1073        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1074    {
1075        let mut results =
1076            self.collapse_columns_multilevel_vec(proj_kn, batch_membership, params)?;
1077        if results.is_empty() {
1078            return Err(anyhow::anyhow!("no levels processed"));
1079        }
1080        Ok(results.remove(0))
1081    }
1082
1083    fn collapse_columns_multilevel_vec<T>(
1084        &mut self,
1085        proj_kn: &DMatrix<f32>,
1086        batch_membership: &[T],
1087        params: &MultilevelParams,
1088    ) -> anyhow::Result<Vec<CollapsedOut>>
1089    where
1090        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1091    {
1092        let sort_dim = params.sort_dim;
1093        let knn = params.knn_pb_samples;
1094        let opt_iter = params.num_opt_iter;
1095
1096        self.register_batch_membership(batch_membership);
1097        let num_features = self.num_rows();
1098        let num_batches = self.num_batches();
1099        if num_batches >= 2 {
1100            self.build_hnsw_per_batch(proj_kn, batch_membership)?;
1101        }
1102
1103        // Level dims: [finest, ..., coarsest]
1104        let level_dims = compute_level_sort_dims(sort_dim, params.num_levels);
1105
1106        info!(
1107            "Multi-level collapsing (fine→coarse): {} levels, sort_dims={:?}, {} batches",
1108            level_dims.len(),
1109            level_dims,
1110            num_batches,
1111        );
1112
1113        // Compute binary codes at finest resolution once
1114        let FinestCodes {
1115            codes: fine_codes,
1116            widths: level_dims,
1117            tree: pb_tree,
1118            strata_bits,
1119        } = finest_codes(self, proj_kn, &level_dims, params)?;
1120
1121        // Partition at finest level
1122        self.assign_groups(&fine_codes, None);
1123
1124        let group_to_cols = self
1125            .take_grouped_columns()
1126            .ok_or(anyhow::anyhow!("columns not assigned"))?
1127            .clone();
1128
1129        ////////////////////////////////////////////////////////////////////
1130        // Opt-in refinement path: BBKNN + Poisson DC-SBM over pb-samples //
1131        ////////////////////////////////////////////////////////////////////
1132
1133        let refine_params = &params.refine;
1134        let anchor_batches =
1135            resolve_named_batches(self, "anchor", params.anchor_batches.as_deref())?;
1136        let bulk_batches = resolve_named_batches(self, "bulk", params.bulk_batches.as_deref())?;
1137        ensure_disjoint_roles(anchor_batches.as_deref(), bulk_batches.as_deref())?;
1138        let summary_batches = anchor_batches.clone();
1139        let anchor_batches = greedy_anchor_for_bulk(self, anchor_batches, bulk_batches.as_deref());
1140        let ctx = RefineCollectCtx {
1141            fine_codes: &fine_codes,
1142            group_to_cols_finest: &group_to_cols,
1143            level_dims: &level_dims,
1144            num_features,
1145            num_batches,
1146            knn,
1147            opt_iter,
1148            refine_params,
1149            output_calibration: params.output_calibration,
1150            anchor_batches: anchor_batches.as_deref(),
1151            summary_batches: summary_batches.as_deref(),
1152            bulk_batches: bulk_batches.as_deref(),
1153            observe_panels: params.observe_panels,
1154            keep_finest_stats: params.keep_finest_stats,
1155            pb_tree: pb_tree.as_ref(),
1156            cell_to_stratum: params.strata.as_deref(),
1157            exclude_unmatched_from_delta: params.strata.is_some(),
1158            strata_bits,
1159        };
1160        refine_and_collect_single_layer(self, proj_kn, &ctx).map(|out| out.levels)
1161    }
1162}
1163
1164impl MultilevelCollapsingOps for SparseIoStack {
1165    type LevelOutput = Vec<CollapsedOut>;
1166
1167    fn collapse_columns_multilevel<T>(
1168        &mut self,
1169        proj_kn: &DMatrix<f32>,
1170        batch_membership: &[T],
1171        params: &MultilevelParams,
1172    ) -> anyhow::Result<Vec<CollapsedOut>>
1173    where
1174        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1175    {
1176        let mut results =
1177            self.collapse_columns_multilevel_vec(proj_kn, batch_membership, params)?;
1178        if results.is_empty() {
1179            return Err(anyhow::anyhow!("no levels processed"));
1180        }
1181        Ok(results.remove(0))
1182    }
1183
1184    fn collapse_columns_multilevel_vec<T>(
1185        &mut self,
1186        proj_kn: &DMatrix<f32>,
1187        batch_membership: &[T],
1188        params: &MultilevelParams,
1189    ) -> anyhow::Result<Vec<Vec<CollapsedOut>>>
1190    where
1191        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1192    {
1193        Ok(
1194            collapse_stack_multilevel_with_hierarchy(self, proj_kn, batch_membership, params)?
1195                .levels,
1196        )
1197    }
1198}
1199
1200/// The `SparseIoStack` collapse, also returning the per-level cell → pb
1201/// membership (finest first) the layers share — the stack counterpart of
1202/// [`collapse_columns_multilevel_with_hierarchy`]. With
1203/// `params.keep_finest_stats` the finest level keeps its sufficient statistics,
1204/// which `CollapsedOut::observed_counts` reads.
1205pub fn collapse_stack_multilevel_with_hierarchy<T>(
1206    stack: &mut SparseIoStack,
1207    proj_kn: &DMatrix<f32>,
1208    batch_membership: &[T],
1209    params: &MultilevelParams,
1210) -> anyhow::Result<StackCollapseOut>
1211where
1212    T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1213{
1214    let num_layers = stack.num_types();
1215    if num_layers == 0 {
1216        return Err(anyhow::anyhow!("empty SparseIoStack"));
1217    }
1218
1219    let sort_dim = params.sort_dim;
1220    let knn = params.knn_pb_samples;
1221    let opt_iter = params.num_opt_iter;
1222
1223    stack.register_batch_membership(batch_membership);
1224
1225    // Use first layer for num_batches (all layers share the same columns)
1226    let num_batches = stack.stack[0].num_batches();
1227    if num_batches >= 2 {
1228        for layer in stack.stack.iter_mut() {
1229            layer.build_hnsw_per_batch(proj_kn, batch_membership)?;
1230        }
1231    }
1232
1233    let ncols = proj_kn.ncols();
1234    let refine_params = &params.refine;
1235    let level_dims = compute_level_sort_dims(sort_dim, params.num_levels);
1236    let finest_dim = level_dims[0];
1237    let kk = proj_kn.nrows().min(finest_dim).min(ncols);
1238    let codes = binary_sort_columns(proj_kn, kk)?;
1239    let (fine_codes, level_dims, strata_bits) = match params.strata.as_deref() {
1240        Some(strata) => {
1241            let (c, d, s) = apply_strata_to_codes(&codes, &level_dims, strata)?;
1242            (c, d, s)
1243        }
1244        None => (codes, level_dims, 0),
1245    };
1246    for layer in stack.stack.iter_mut() {
1247        layer.assign_groups(&fine_codes, None);
1248    }
1249    let group_to_cols = stack.stack[0]
1250        .take_grouped_columns()
1251        .ok_or(anyhow::anyhow!("columns not assigned"))?
1252        .clone();
1253    let num_features = stack.stack[0].num_rows();
1254    anyhow::ensure!(
1255        params.anchor_batches.is_none() && params.bulk_batches.is_none(),
1256        "anchor_batches / bulk_batches are not supported on the stack path — nothing \
1257         produces a carried reference or bulk input for stacked modalities"
1258    );
1259    let ctx = RefineCollectCtx {
1260        fine_codes: &fine_codes,
1261        group_to_cols_finest: &group_to_cols,
1262        level_dims: &level_dims,
1263        num_features,
1264        num_batches,
1265        knn,
1266        opt_iter,
1267        refine_params,
1268        output_calibration: params.output_calibration,
1269        anchor_batches: None,
1270        summary_batches: None,
1271        bulk_batches: None,
1272        observe_panels: false,
1273        keep_finest_stats: params.keep_finest_stats,
1274        pb_tree: None,
1275        cell_to_stratum: params.strata.as_deref(),
1276        exclude_unmatched_from_delta: params.strata.is_some(),
1277        strata_bits,
1278    };
1279    refine_and_collect_stack(stack, proj_kn, &ctx)
1280}