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, collect_pb_sample_gene_sums,
40};
41pub use pb_samples::{PbSampleCollection, PbSampleLayout};
42// Shared cross-batch pb-sample matching, reused by `refine_multilevel`.
43pub(crate) use pb_samples::per_batch_sc_neighbors;
44mod reassign_cells;
45pub use reassign_cells::ReassignCellsParams;
46mod pb_tree;
47pub use pb_tree::{BelowEdge, ContrastGene, PbTree, PbTreeParams, RootRecord, SplitRecord};
48mod refine;
49pub use refine::StackCollapseOut;
50use refine::{
51    compute_level_sort_dims, fine_to_coarse_from_refined, pad_numeric_labels,
52    refine_and_collect_single_layer, refine_and_collect_stack, split_anchored_finest_groups,
53    RefineCollectCtx,
54};
55mod stats;
56use stats::{
57    collect_basic_stat_visitor, collect_batch_stat_visitor, collect_matched_stat_coarse,
58    collect_matched_stat_visitor, collect_observed_stat_from_pb_samples, merge_stat, optimize,
59    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 covered_rows: Vec<Vec<usize>> = Vec::with_capacity(coverage.len());
467    for cov in coverage.iter() {
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        covered_rows.push((0..num_genes).filter(|&g| cov[g]).collect());
474    }
475    // One sample (a contiguous column of the column-major plane) per worker;
476    // a source without mass in the sample adds nothing and is skipped.
477    let mut size_ds = DMatrix::<f32>::zeros(num_genes, num_samples);
478    if num_genes > 0 {
479        size_ds
480            .as_mut_slice()
481            .par_chunks_mut(num_genes)
482            .enumerate()
483            .for_each(|(s, size_s)| {
484                for (src, rows) in covered_rows.iter().enumerate() {
485                    let mass = count_bs[(src, s)];
486                    if mass == 0.0 {
487                        continue;
488                    }
489                    for &g in rows {
490                        size_s[g] += mass;
491                    }
492                }
493            });
494    }
495
496    let mut mask_db = DMatrix::<f32>::zeros(num_genes, num_batches);
497    for (src, rows) in covered_rows.iter().enumerate() {
498        for (b, &used) in source_in_batch[src].iter().enumerate() {
499            if used {
500                for &g in rows {
501                    mask_db[(g, b)] = 1.0;
502                }
503            }
504        }
505    }
506
507    info!(
508        "panel observability attached: {} of {} (gene, sample) entries below full size",
509        size_ds
510            .column_iter()
511            .enumerate()
512            .map(|(s, col)| col.iter().filter(|&&v| v < stat.size_s[s]).count())
513            .sum::<usize>(),
514        num_genes * num_samples,
515    );
516    stat.size_ds = Some(size_ds);
517    stat.obs_mask_db = (mask_db.iter().any(|&v| v == 0.0)).then_some(mask_db);
518    Ok(())
519}
520
521pub struct EmptyArg {}
522
523#[cfg(debug_assertions)]
524use log::debug;
525
526/// Given a feature/projection matrix (factor x cells), we assign each
527/// cell to a sample and return pseudobulk (collapsed) matrices
528///
529/// (1) Register batches if needed (2) collapse columns/cells into samples
530///
531pub trait CollapsingOps {
532    ///
533    /// Collapse columns/cells into samples as allocated by
534    /// `assign_columns_to_samples`
535    ///
536    /// # Arguments
537    /// * `cells_per_group` - number of cells per sample (None: no down sampling)
538    /// * `knn_batches` - number of nearest neighbour batches
539    /// * `knn_cells` - number of nearest neighbors for building HNSW (default: 10)
540    /// * `reference` - reference batch for counterfactual inference
541    /// * `num_opt_iter` - number of optimization iterations (default: 100)
542    ///
543    fn collapse_columns(
544        &self,
545        knn_batches: Option<usize>,
546        knn_cells: Option<usize>,
547        reference_batch_names: Option<&[Box<str>]>,
548        num_opt_iter: Option<usize>,
549    ) -> anyhow::Result<CollapsedOut>;
550
551    /// Register batch information and build a `HnswMap` object for
552    /// each batch for fast nearest neighbor search within each batch
553    /// and store them in the `SparseIoVec`
554    ///
555    /// # Arguments
556    /// * `proj_kn` - random projection matrix
557    /// * `col_to_batch` - map: cell -> batch
558    fn build_hnsw_per_batch<T>(
559        &mut self,
560        proj_kn: &nalgebra::DMatrix<f32>,
561        col_to_batch: &[T],
562    ) -> anyhow::Result<()>
563    where
564        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString;
565
566    fn collect_basic_stat(&self, stat: &mut CollapsedStat) -> anyhow::Result<()>;
567
568    fn collect_batch_stat(&self, stat: &mut CollapsedStat) -> anyhow::Result<()>;
569
570    fn collect_matched_stat(
571        &self,
572        knn_batches: usize,
573        knn_cols: usize,
574        reference_indices: Option<&[usize]>,
575        stat: &mut CollapsedStat,
576    ) -> anyhow::Result<()>;
577}
578
579impl CollapsingOps for SparseIoVec {
580    fn build_hnsw_per_batch<T>(
581        &mut self,
582        proj_kn: &nalgebra::DMatrix<f32>,
583        col_to_batch: &[T],
584    ) -> anyhow::Result<()>
585    where
586        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
587    {
588        info!("creating batch-specific HNSW maps ...");
589        self.register_batches_dmatrix(proj_kn, col_to_batch)?;
590
591        info!(
592            "found {} columns across {} batches",
593            self.num_columns(),
594            self.num_batches()
595        );
596
597        Ok(())
598    }
599
600    fn collapse_columns(
601        &self,
602        knn_batches: Option<usize>,
603        knn_cells: Option<usize>,
604        reference_batch_names: Option<&[Box<str>]>,
605        num_opt_iter: Option<usize>,
606    ) -> anyhow::Result<CollapsedOut> {
607        let group_to_cols = self.take_grouped_columns().ok_or(anyhow::anyhow!(
608            "The columns were not assigned before. Call `assign_columns_to_groups`"
609        ))?;
610
611        let num_features = self.num_rows();
612        let num_groups = group_to_cols.len();
613        let num_batches = self.num_batches();
614
615        let mut stat = CollapsedStat::new(num_features, num_groups, num_batches);
616        info!("basic statistics across {} groups", num_groups);
617        self.collect_basic_stat(&mut stat)?;
618
619        if num_batches > 1 {
620            info!(
621                "batch-specific statistics across {} batches over {} samples",
622                num_batches, num_groups
623            );
624
625            let batch_name_map = self
626                .batch_name_map()
627                .ok_or(anyhow::anyhow!("unable to read batch names"))?;
628
629            let reference_indices = reference_batch_names.map(|x| {
630                x.iter()
631                    .filter_map(|b| batch_name_map.get(b))
632                    .copied()
633                    .collect::<Vec<_>>()
634            });
635
636            if let Some(r) = reference_indices.as_ref() {
637                if r.is_empty() {
638                    let ref_names = reference_batch_names
639                        .unwrap()
640                        .iter()
641                        .map(|x| x.to_string())
642                        .collect::<Vec<_>>()
643                        .join(",");
644
645                    let bat_names = self
646                        .batch_names()
647                        .unwrap()
648                        .iter()
649                        .map(|x| x.to_string())
650                        .collect::<Vec<_>>()
651                        .join(",");
652
653                    warn!("{} vs. {}", ref_names, bat_names);
654
655                    return Err(anyhow::anyhow!("no reference batch names matched!"));
656                }
657            }
658
659            self.collect_batch_stat(&mut stat)?;
660
661            info!(
662                "counterfactual inference across {} batches over {} samples",
663                num_batches, num_groups,
664            );
665
666            let knn_batches = knn_batches.unwrap_or(2);
667            let knn_cells = knn_cells.unwrap_or(DEFAULT_KNN);
668
669            self.collect_matched_stat(
670                knn_batches,
671                knn_cells,
672                reference_indices.as_deref(),
673                &mut stat,
674            )?;
675        } // if num_batches > 1
676
677        /////////////////////////////
678        // Resolve mean parameters //
679        /////////////////////////////
680
681        info!("optimizing the collapsed parameters...");
682        let (a0, b0) = (1_f32, 1_f32);
683        optimize(
684            &stat,
685            (a0, b0),
686            num_opt_iter.unwrap_or(DEFAULT_OPT_ITER),
687            "Optimizing",
688            CalibrateTarget::All,
689            false,
690        )
691    }
692
693    fn collect_basic_stat(&self, stat: &mut CollapsedStat) -> anyhow::Result<()> {
694        self.visit_columns_by_group(&collect_basic_stat_visitor, &EmptyArg {}, stat)
695    }
696
697    fn collect_batch_stat(&self, stat: &mut CollapsedStat) -> anyhow::Result<()> {
698        self.visit_columns_by_group(&collect_batch_stat_visitor, &EmptyArg {}, stat)
699    }
700
701    fn collect_matched_stat(
702        &self,
703        knn_batches: usize,
704        knn_cells: usize,
705        reference_indices: Option<&[usize]>,
706        stat: &mut CollapsedStat,
707    ) -> anyhow::Result<()> {
708        // The reference batches source the counterfactual and pin δ.
709        stat.anchor_batches = reference_indices.map(<[usize]>::to_vec).unwrap_or_default();
710        self.visit_columns_by_group(
711            &collect_matched_stat_visitor,
712            &KnnParams {
713                knn_batches,
714                knn_cells,
715                reference_indices,
716            },
717            stat,
718        )
719    }
720}
721
722pub trait MultilevelCollapsingOps {
723    type LevelOutput;
724
725    fn collapse_columns_multilevel<T>(
726        &mut self,
727        proj_kn: &DMatrix<f32>,
728        batch_membership: &[T],
729        params: &MultilevelParams,
730    ) -> anyhow::Result<Self::LevelOutput>
731    where
732        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString;
733
734    fn collapse_columns_multilevel_vec<T>(
735        &mut self,
736        proj_kn: &DMatrix<f32>,
737        batch_membership: &[T],
738        params: &MultilevelParams,
739    ) -> anyhow::Result<Vec<Self::LevelOutput>>
740    where
741        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString;
742}
743
744/// Same as `SparseIoVec::collapse_columns_multilevel_vec`, but also returns
745/// the per-level cell → pb mapping needed for hierarchical / nested
746/// chain sampling in downstream consumers (currently
747/// `graph-embedding-util`).
748pub fn collapse_columns_multilevel_with_hierarchy<T>(
749    data_vec: &mut SparseIoVec,
750    proj_kn: &DMatrix<f32>,
751    batch_membership: &[T],
752    params: &MultilevelParams,
753) -> anyhow::Result<MultilevelCollapseOut>
754where
755    T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
756{
757    let sort_dim = params.sort_dim;
758    let knn = params.knn_pb_samples;
759    let opt_iter = params.num_opt_iter;
760
761    data_vec.register_batch_membership(batch_membership);
762    let num_features = data_vec.num_rows();
763    let num_batches = data_vec.num_batches();
764    if num_batches >= 2 {
765        data_vec.build_hnsw_per_batch(proj_kn, batch_membership)?;
766    }
767
768    let level_dims = compute_level_sort_dims(sort_dim, params.num_levels);
769    let FinestCodes {
770        codes: fine_codes,
771        widths: level_dims,
772        tree: pb_tree,
773        strata_bits,
774    } = finest_codes(data_vec, proj_kn, &level_dims, params)?;
775    data_vec.assign_groups(&fine_codes, None);
776
777    let group_to_cols = data_vec
778        .take_grouped_columns()
779        .ok_or_else(|| anyhow::anyhow!("columns not assigned"))?
780        .clone();
781
782    let refine_params = &params.refine;
783
784    let anchor_batches =
785        resolve_named_batches(data_vec, "anchor", params.anchor_batches.as_deref())?;
786    let bulk_batches = resolve_named_batches(data_vec, "bulk", params.bulk_batches.as_deref())?;
787    ensure_disjoint_roles(anchor_batches.as_deref(), bulk_batches.as_deref())?;
788    // `summary_batches` are the batches whose columns are ALREADY summaries
789    // (a carried pb_reference, or bulk) and so must never be re-averaged.
790    // `anchor_batches` below is the MATCHING frame, which after the greedy
791    // default is the cells — and cells must still collapse into pseudobulks.
792    // Conflating the two turns every cell into its own pb-sample.
793    let summary_batches = anchor_batches.clone();
794    let anchor_batches = greedy_anchor_for_bulk(data_vec, anchor_batches, bulk_batches.as_deref());
795    let ctx = RefineCollectCtx {
796        fine_codes: &fine_codes,
797        group_to_cols_finest: &group_to_cols,
798        level_dims: &level_dims,
799        num_features,
800        num_batches,
801        knn,
802        opt_iter,
803        refine_params,
804        output_calibration: params.output_calibration,
805        anchor_batches: anchor_batches.as_deref(),
806        summary_batches: summary_batches.as_deref(),
807        bulk_batches: bulk_batches.as_deref(),
808        observe_panels: params.observe_panels,
809        keep_finest_stats: params.keep_finest_stats,
810        pb_tree: pb_tree.as_ref(),
811        cell_to_stratum: params.strata.as_deref(),
812        exclude_unmatched_from_delta: params.strata.is_some(),
813        strata_bits,
814    };
815    refine_and_collect_single_layer(data_vec, proj_kn, &ctx)
816}
817
818/// Variant of [`collapse_columns_multilevel_with_hierarchy`] that **skips
819/// the BBKNN + Poisson DC-SBM refinement**, instead synthesising the
820/// per-pb-sample group assignment from a caller-supplied
821/// `cell_to_pb_per_level` (finest-first; the same shape returned in
822/// [`MultilevelCollapseOut.cell_to_pb_per_level`] by the refining
823/// entry point). Typical use: `senna {topic, itopic, ce-topic} --from`
824/// inheriting a prior run's partition.
825///
826/// The finest level's pb-sample → group label is decided by majority vote
827/// across the cells in that pb-sample; each coarser level's label is then
828/// voted per group of the level below it, so the returned levels always
829/// nest, whether or not the inherited ones do. The downstream `optimize` step
830/// still runs per level, so the returned `CollapsedOut` posteriors
831/// reflect this run's batch model and priors — only the expensive
832/// clustering work is bypassed.
833pub fn collapse_columns_multilevel_with_partition<T>(
834    data_vec: &mut SparseIoVec,
835    proj_kn: &DMatrix<f32>,
836    batch_membership: &[T],
837    params: &MultilevelParams,
838    cell_to_pb_per_level: &[Vec<usize>],
839) -> anyhow::Result<MultilevelCollapseOut>
840where
841    T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
842{
843    let knn = params.knn_pb_samples;
844    let opt_iter = params.num_opt_iter;
845
846    data_vec.register_batch_membership(batch_membership);
847    let num_features = data_vec.num_rows();
848    let num_batches = data_vec.num_batches();
849    if num_batches >= 2 {
850        data_vec.build_hnsw_per_batch(proj_kn, batch_membership)?;
851    }
852
853    let level_dims = compute_level_sort_dims(params.sort_dim, params.num_levels);
854    anyhow::ensure!(
855        cell_to_pb_per_level.len() == level_dims.len(),
856        "inherited cell_to_pb has {} levels but --num-levels is {}; \
857         pass --num-levels to match the source run",
858        cell_to_pb_per_level.len(),
859        level_dims.len(),
860    );
861    // pb-samples are built from the INHERITED finest membership, so each
862    // pb-sample is one (batch, source finest group) intersection and the
863    // per-level vote below is unanimous by construction. Fresh marginal
864    // leaves would straddle a source partition whose high bits came from
865    // within-node residuals, and a modal vote over them would erode it.
866    // pb-samples are still needed for the cross-batch matched-stat path on
867    // multi-batch data; refinement is what we skip.
868    let inherited_finest = &cell_to_pb_per_level[0];
869    anyhow::ensure!(
870        inherited_finest.len() == proj_kn.ncols(),
871        "inherited cell_to_pb finest level has {} cells, data has {}",
872        inherited_finest.len(),
873        proj_kn.ncols()
874    );
875    let k_inherited = inherited_finest.iter().copied().max().map_or(0, |m| m + 1);
876    data_vec.assign_groups(&pad_numeric_labels(inherited_finest, k_inherited), None);
877
878    let anchor_batches =
879        resolve_named_batches(data_vec, "anchor", params.anchor_batches.as_deref())?;
880    let bulk_batches = resolve_named_batches(data_vec, "bulk", params.bulk_batches.as_deref())?;
881    ensure_disjoint_roles(anchor_batches.as_deref(), bulk_batches.as_deref())?;
882    // `summary_batches` are the batches whose columns are ALREADY summaries
883    // (a carried pb_reference, or bulk) and so must never be re-averaged.
884    // `anchor_batches` below is the MATCHING frame, which after the greedy
885    // default is the cells — and cells must still collapse into pseudobulks.
886    // Conflating the two turns every cell into its own pb-sample.
887    let summary_batches = anchor_batches.clone();
888    let anchor_batches = greedy_anchor_for_bulk(data_vec, anchor_batches, bulk_batches.as_deref());
889    let pb_samples = build_pb_samples(
890        data_vec,
891        proj_kn,
892        num_features,
893        summary_batches.as_deref().unwrap_or(&[]),
894        bulk_batches.as_deref().unwrap_or(&[]),
895        params.strata.as_deref(),
896    )?;
897    let num_pb = pb_samples.layout.cell_counts.len();
898    let ncols = proj_kn.ncols();
899    let pb_sample_to_cells = build_pb_sample_to_cells(&pb_samples.layout);
900
901    // Synthesize a RefinedAssignment from the inherited cell→pb
902    // membership per pb-sample at each level. The vote is unanimous at the
903    // finest level by construction; coarser levels vote in case the source
904    // hierarchy was not strictly nested.
905    let num_levels = level_dims.len();
906    for (lvl_idx, lvl) in cell_to_pb_per_level.iter().enumerate() {
907        anyhow::ensure!(
908            lvl.len() == ncols,
909            "inherited cell_to_pb level {} has {} cells, data has {}",
910            lvl_idx,
911            lvl.len(),
912            ncols
913        );
914    }
915    let (pbsamp_to_group, num_groups_per_level) =
916        nested_levels_by_vote(&pb_sample_to_cells, cell_to_pb_per_level);
917    let refined = split_anchored_finest_groups(
918        crate::alg::refine_multilevel::RefinedAssignment {
919            pbsamp_to_group,
920            num_groups_per_level,
921        },
922        &pb_samples.layout,
923    );
924
925    info!(
926        "Inherited partition: {} cells, {} pb-samples, finest k={} (skipped BBKNN + DC-SBM refinement)",
927        ncols, num_pb, refined.num_groups_per_level[0]
928    );
929
930    // From here the path matches refine_and_collect_single_layer's
931    // post-refinement tail: assign groups, collect stats, fit Gamma
932    // posteriors level-by-level via merge_stat.
933    let k_finest = refined.num_groups_per_level[0];
934    let mut cell_to_group_finest = vec![0usize; ncols];
935    for (pbsamp, cells) in pb_sample_to_cells.iter().enumerate() {
936        let g = refined.pbsamp_to_group[0][pbsamp];
937        for &c in cells {
938            cell_to_group_finest[c] = g;
939        }
940    }
941    let finest_str = pad_numeric_labels(&cell_to_group_finest, k_finest);
942    data_vec.assign_groups(&finest_str, None);
943    debug_assert_eq!(data_vec.num_groups(), k_finest);
944
945    let mut fine_stat = CollapsedStat::new(num_features, k_finest, num_batches);
946    fine_stat.exclude_unmatched_from_delta = params.strata.is_some();
947    info!(
948        "Collecting observed stats over {} groups × {} batches from {} pb-samples ...",
949        k_finest, num_batches, num_pb
950    );
951    collect_observed_stat_from_pb_samples(
952        data_vec,
953        &pb_samples.layout,
954        &pb_samples.gene_sums,
955        &refined.pbsamp_to_group[0],
956        &mut fine_stat,
957    )?;
958    if num_batches >= 2 {
959        let batch_knn = data_vec
960            .batch_knn_lookup()
961            .ok_or_else(|| anyhow::anyhow!("batch_knn_lookup not built"))?;
962        info!(
963            "Collecting cross-batch matched stats (knn={}) over {} pb-samples ...",
964            knn, num_pb
965        );
966        let matches = per_batch_sc_neighbors(
967            &pb_samples.layout,
968            batch_knn.as_slice(),
969            knn,
970            anchor_batches.as_deref(),
971        )?;
972        collect_matched_stat_coarse(
973            &pb_samples.layout,
974            &pb_samples.gene_sums,
975            &refined.pbsamp_to_group[0],
976            &matches,
977            anchor_batches.as_deref(),
978            &mut fine_stat,
979        )?;
980    }
981    if params.observe_panels {
982        attach_observability(&mut fine_stat, data_vec)?;
983    }
984
985    let mut results: Vec<CollapsedOut> = Vec::with_capacity(num_levels);
986    info!(
987        "Level 1/{}: inherited k={} (finest; {} cells)",
988        num_levels, k_finest, ncols
989    );
990    let finest_out = optimize(
991        &fine_stat,
992        (1.0, 1.0),
993        opt_iter,
994        &format!("Inherit L1/{}", num_levels),
995        CalibrateTarget::All,
996        false,
997    )?;
998    results.push(finest_out);
999
1000    let mut prev_stat = fine_stat;
1001    for level in 1..num_levels {
1002        let k_prev = refined.num_groups_per_level[level - 1];
1003        let k_level = refined.num_groups_per_level[level];
1004        let fine_to_coarse = fine_to_coarse_from_refined(
1005            &refined.pbsamp_to_group[level - 1],
1006            &refined.pbsamp_to_group[level],
1007            k_prev,
1008        );
1009        let coarse_stat = merge_stat(&prev_stat, &fine_to_coarse, k_level);
1010        info!(
1011            "Level {}/{}: inherited k={} (merged from {})",
1012            level + 1,
1013            num_levels,
1014            k_level,
1015            k_prev
1016        );
1017        let level_opt_iter = (opt_iter / 2).max(10);
1018        let out = optimize(
1019            &coarse_stat,
1020            (1.0, 1.0),
1021            level_opt_iter,
1022            &format!("Inherit L{}/{}", level + 1, num_levels),
1023            CalibrateTarget::All,
1024            false,
1025        )?;
1026        results.push(out);
1027        prev_stat = coarse_stat;
1028    }
1029    info!(
1030        "Fitted pseudobulk posteriors for {} inherited levels: k = {:?} (finest first)",
1031        num_levels, refined.num_groups_per_level
1032    );
1033
1034    // Re-derive cell_to_pb_per_level from the post-modal-vote groups so
1035    // the returned struct is self-consistent — small drift vs the
1036    // inherited values is expected when pb-samples spanned multiple
1037    // source groups (handled by majority).
1038    let mut cell_to_pb_per_level_out: Vec<Vec<usize>> = Vec::with_capacity(num_levels);
1039    for level in 0..num_levels {
1040        let mut c2g = vec![0usize; ncols];
1041        for (pbsamp, cells) in pb_sample_to_cells.iter().enumerate() {
1042            let g = refined.pbsamp_to_group[level][pbsamp];
1043            for &c in cells {
1044                c2g[c] = g;
1045            }
1046        }
1047        cell_to_pb_per_level_out.push(c2g);
1048    }
1049
1050    Ok(MultilevelCollapseOut {
1051        levels: results,
1052        cell_to_pb_per_level: cell_to_pb_per_level_out,
1053        pb_tree: None,
1054    })
1055}
1056
1057/// Per-level pb-sample → group labels (compacted, finest first) and group
1058/// counts from an inherited per-level cell → group membership.
1059///
1060/// The finest level takes each pb-sample's modal label. Every coarser level
1061/// is voted per group of the level below it — the group takes the label most
1062/// of its cells carry, ties going to the smaller label — so each level nests
1063/// in the next whether or not the inherited hierarchy did, which is what
1064/// `fine_to_coarse_from_refined` and `merge_stat` rely on.
1065fn nested_levels_by_vote(
1066    pb_sample_to_cells: &[Vec<usize>],
1067    cell_to_group_per_level: &[Vec<usize>],
1068) -> (Vec<Vec<usize>>, Vec<usize>) {
1069    use rustc_hash::FxHashMap;
1070    let mut pbsamp_to_group: Vec<Vec<usize>> = Vec::with_capacity(cell_to_group_per_level.len());
1071    let mut num_groups_per_level: Vec<usize> = Vec::with_capacity(cell_to_group_per_level.len());
1072    for lvl in cell_to_group_per_level {
1073        let voted: Vec<usize> = match pbsamp_to_group.last() {
1074            None => pb_sample_to_cells
1075                .iter()
1076                .map(|cells| modal_group(cells, lvl))
1077                .collect(),
1078            Some(finer) => {
1079                let k_finer = num_groups_per_level.last().copied().unwrap_or(0);
1080                let mut votes: Vec<FxHashMap<usize, usize>> = vec![FxHashMap::default(); k_finer];
1081                for (cells, &g) in pb_sample_to_cells.iter().zip(finer) {
1082                    for &c in cells {
1083                        *votes[g].entry(lvl[c]).or_insert(0) += 1;
1084                    }
1085                }
1086                let winner: Vec<usize> = votes
1087                    .iter()
1088                    .map(|v| {
1089                        v.iter()
1090                            .max_by_key(|&(&label, &n)| (n, std::cmp::Reverse(label)))
1091                            .map_or(0, |(&label, _)| label)
1092                    })
1093                    .collect();
1094                finer.iter().map(|&g| winner[g]).collect()
1095            }
1096        };
1097        let (compact, k) = crate::alg::refine_multilevel::compact_labels(&voted);
1098        num_groups_per_level.push(k);
1099        pbsamp_to_group.push(compact);
1100    }
1101    (pbsamp_to_group, num_groups_per_level)
1102}
1103
1104/// Modal `lvl[c]` over `c ∈ cells`. Returns 0 when `cells` is empty.
1105/// Fast small-Vec path for the common case where pb-samples contain
1106/// just a handful of cells.
1107fn modal_group(cells: &[usize], lvl: &[usize]) -> usize {
1108    match cells {
1109        [] => 0,
1110        [c] => lvl[*c],
1111        _ => {
1112            use rustc_hash::FxHashMap;
1113            let mut counts: FxHashMap<usize, usize> = FxHashMap::default();
1114            for &c in cells {
1115                *counts.entry(lvl[c]).or_insert(0) += 1;
1116            }
1117            counts
1118                .into_iter()
1119                .max_by_key(|&(_, n)| n)
1120                .map(|(g, _)| g)
1121                .unwrap_or(0)
1122        }
1123    }
1124}
1125
1126impl MultilevelCollapsingOps for SparseIoVec {
1127    type LevelOutput = CollapsedOut;
1128
1129    fn collapse_columns_multilevel<T>(
1130        &mut self,
1131        proj_kn: &DMatrix<f32>,
1132        batch_membership: &[T],
1133        params: &MultilevelParams,
1134    ) -> anyhow::Result<CollapsedOut>
1135    where
1136        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1137    {
1138        let mut results =
1139            self.collapse_columns_multilevel_vec(proj_kn, batch_membership, params)?;
1140        if results.is_empty() {
1141            return Err(anyhow::anyhow!("no levels processed"));
1142        }
1143        Ok(results.remove(0))
1144    }
1145
1146    fn collapse_columns_multilevel_vec<T>(
1147        &mut self,
1148        proj_kn: &DMatrix<f32>,
1149        batch_membership: &[T],
1150        params: &MultilevelParams,
1151    ) -> anyhow::Result<Vec<CollapsedOut>>
1152    where
1153        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1154    {
1155        let sort_dim = params.sort_dim;
1156        let knn = params.knn_pb_samples;
1157        let opt_iter = params.num_opt_iter;
1158
1159        self.register_batch_membership(batch_membership);
1160        let num_features = self.num_rows();
1161        let num_batches = self.num_batches();
1162        if num_batches >= 2 {
1163            self.build_hnsw_per_batch(proj_kn, batch_membership)?;
1164        }
1165
1166        // Level dims: [finest, ..., coarsest]
1167        let level_dims = compute_level_sort_dims(sort_dim, params.num_levels);
1168
1169        info!(
1170            "Multi-level collapsing (fine→coarse): {} levels, sort_dims={:?}, {} batches",
1171            level_dims.len(),
1172            level_dims,
1173            num_batches,
1174        );
1175
1176        // Compute binary codes at finest resolution once
1177        let FinestCodes {
1178            codes: fine_codes,
1179            widths: level_dims,
1180            tree: pb_tree,
1181            strata_bits,
1182        } = finest_codes(self, proj_kn, &level_dims, params)?;
1183
1184        // Partition at finest level
1185        self.assign_groups(&fine_codes, None);
1186
1187        let group_to_cols = self
1188            .take_grouped_columns()
1189            .ok_or(anyhow::anyhow!("columns not assigned"))?
1190            .clone();
1191
1192        ////////////////////////////////////////////////////////////////////
1193        // Opt-in refinement path: BBKNN + Poisson DC-SBM over pb-samples //
1194        ////////////////////////////////////////////////////////////////////
1195
1196        let refine_params = &params.refine;
1197        let anchor_batches =
1198            resolve_named_batches(self, "anchor", params.anchor_batches.as_deref())?;
1199        let bulk_batches = resolve_named_batches(self, "bulk", params.bulk_batches.as_deref())?;
1200        ensure_disjoint_roles(anchor_batches.as_deref(), bulk_batches.as_deref())?;
1201        let summary_batches = anchor_batches.clone();
1202        let anchor_batches = greedy_anchor_for_bulk(self, anchor_batches, bulk_batches.as_deref());
1203        let ctx = RefineCollectCtx {
1204            fine_codes: &fine_codes,
1205            group_to_cols_finest: &group_to_cols,
1206            level_dims: &level_dims,
1207            num_features,
1208            num_batches,
1209            knn,
1210            opt_iter,
1211            refine_params,
1212            output_calibration: params.output_calibration,
1213            anchor_batches: anchor_batches.as_deref(),
1214            summary_batches: summary_batches.as_deref(),
1215            bulk_batches: bulk_batches.as_deref(),
1216            observe_panels: params.observe_panels,
1217            keep_finest_stats: params.keep_finest_stats,
1218            pb_tree: pb_tree.as_ref(),
1219            cell_to_stratum: params.strata.as_deref(),
1220            exclude_unmatched_from_delta: params.strata.is_some(),
1221            strata_bits,
1222        };
1223        refine_and_collect_single_layer(self, proj_kn, &ctx).map(|out| out.levels)
1224    }
1225}
1226
1227impl MultilevelCollapsingOps for SparseIoStack {
1228    type LevelOutput = Vec<CollapsedOut>;
1229
1230    fn collapse_columns_multilevel<T>(
1231        &mut self,
1232        proj_kn: &DMatrix<f32>,
1233        batch_membership: &[T],
1234        params: &MultilevelParams,
1235    ) -> anyhow::Result<Vec<CollapsedOut>>
1236    where
1237        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1238    {
1239        let mut results =
1240            self.collapse_columns_multilevel_vec(proj_kn, batch_membership, params)?;
1241        if results.is_empty() {
1242            return Err(anyhow::anyhow!("no levels processed"));
1243        }
1244        Ok(results.remove(0))
1245    }
1246
1247    fn collapse_columns_multilevel_vec<T>(
1248        &mut self,
1249        proj_kn: &DMatrix<f32>,
1250        batch_membership: &[T],
1251        params: &MultilevelParams,
1252    ) -> anyhow::Result<Vec<Vec<CollapsedOut>>>
1253    where
1254        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1255    {
1256        Ok(
1257            collapse_stack_multilevel_with_hierarchy(self, proj_kn, batch_membership, params)?
1258                .levels,
1259        )
1260    }
1261}
1262
1263/// The `SparseIoStack` collapse, also returning the per-level cell → pb
1264/// membership (finest first) the layers share — the stack counterpart of
1265/// [`collapse_columns_multilevel_with_hierarchy`]. With
1266/// `params.keep_finest_stats` the finest level keeps its sufficient statistics,
1267/// which `CollapsedOut::observed_counts` reads.
1268pub fn collapse_stack_multilevel_with_hierarchy<T>(
1269    stack: &mut SparseIoStack,
1270    proj_kn: &DMatrix<f32>,
1271    batch_membership: &[T],
1272    params: &MultilevelParams,
1273) -> anyhow::Result<StackCollapseOut>
1274where
1275    T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1276{
1277    let num_layers = stack.num_types();
1278    if num_layers == 0 {
1279        return Err(anyhow::anyhow!("empty SparseIoStack"));
1280    }
1281
1282    let sort_dim = params.sort_dim;
1283    let knn = params.knn_pb_samples;
1284    let opt_iter = params.num_opt_iter;
1285
1286    stack.register_batch_membership(batch_membership);
1287
1288    // Use first layer for num_batches (all layers share the same columns)
1289    let num_batches = stack.stack[0].num_batches();
1290    // The matches below are read off the first layer's lookup (the layers
1291    // share columns and projection); the other layers are given theirs so
1292    // that every layer of the stack carries one afterwards.
1293    if num_batches >= 2 {
1294        for layer in stack.stack.iter_mut() {
1295            layer.build_hnsw_per_batch(proj_kn, batch_membership)?;
1296        }
1297    }
1298
1299    let ncols = proj_kn.ncols();
1300    let refine_params = &params.refine;
1301    let level_dims = compute_level_sort_dims(sort_dim, params.num_levels);
1302    let finest_dim = level_dims[0];
1303    let kk = proj_kn.nrows().min(finest_dim).min(ncols);
1304    let codes = binary_sort_columns(proj_kn, kk)?;
1305    let (fine_codes, level_dims, strata_bits) = match params.strata.as_deref() {
1306        Some(strata) => {
1307            let (c, d, s) = apply_strata_to_codes(&codes, &level_dims, strata)?;
1308            (c, d, s)
1309        }
1310        None => (codes, level_dims, 0),
1311    };
1312    for layer in stack.stack.iter_mut() {
1313        layer.assign_groups(&fine_codes, None);
1314    }
1315    let group_to_cols = stack.stack[0]
1316        .take_grouped_columns()
1317        .ok_or(anyhow::anyhow!("columns not assigned"))?
1318        .clone();
1319    let num_features = stack.stack[0].num_rows();
1320    anyhow::ensure!(
1321        params.anchor_batches.is_none() && params.bulk_batches.is_none(),
1322        "anchor_batches / bulk_batches are not supported on the stack path — nothing \
1323         produces a carried reference or bulk input for stacked modalities"
1324    );
1325    let ctx = RefineCollectCtx {
1326        fine_codes: &fine_codes,
1327        group_to_cols_finest: &group_to_cols,
1328        level_dims: &level_dims,
1329        num_features,
1330        num_batches,
1331        knn,
1332        opt_iter,
1333        refine_params,
1334        output_calibration: params.output_calibration,
1335        anchor_batches: None,
1336        summary_batches: None,
1337        bulk_batches: None,
1338        observe_panels: false,
1339        keep_finest_stats: params.keep_finest_stats,
1340        pb_tree: None,
1341        cell_to_stratum: params.strata.as_deref(),
1342        exclude_unmatched_from_delta: params.strata.is_some(),
1343        strata_bits,
1344    };
1345    refine_and_collect_stack(stack, proj_kn, &ctx)
1346}
1347
1348#[cfg(test)]
1349#[path = "tests.rs"]
1350mod tests;