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