Skip to main content

data_beans/alg/collapse_data/
refine.rs

1//! Multi-level pseudobulk refinement integration.
2//!
3//! Walks the finest hash partition through BBKNN + Poisson DC-SBM
4//! refinement (`crate::alg::refine_multilevel`), then descends level-by-
5//! level via `merge_stat`-style aggregation to emit `CollapsedOut`
6//! posteriors per coarsening level. Two flavors:
7//!
8//! - `refine_and_collect_single_layer`: `SparseIoVec` input (per-cell
9//!   counts; pb-samples are `(batch, group)` intersections).
10//! - `refine_and_collect_stack`: `SparseIoStack` input (per-layer
11//!   stacked observations sharing the first-layer grouping decision).
12//!
13//! Also houses the level-descent helpers (`compute_level_sort_dims`,
14//! `fine_to_coarse_from_refined`) and the trivial-identity fallback
15//! (`refine_or_identity`) used by the single-batch path.
16
17use super::stats::DEFAULT_COARSEST_SORT_DIM;
18use super::*;
19
20pub(super) fn pad_numeric_labels(cell_to_group: &[usize], k: usize) -> Vec<String> {
21    let width = {
22        let mut w = 1usize;
23        let mut n = k.max(1) - 1;
24        while n >= 10 {
25            w += 1;
26            n /= 10;
27        }
28        w
29    };
30    cell_to_group
31        .iter()
32        .map(|g| format!("{:0width$}", g, width = width))
33        .collect()
34}
35
36/// Derive a fine→coarse group mapping from two consecutive refined levels.
37///
38/// The refinement pass enforces hierarchy (sibling-constrained moves), so
39/// all pb-samples sharing a level-`fine` group also share the same
40/// level-`coarse` group. This picks the first pb-sample of each fine group
41/// to read the coarse label.
42pub(super) fn fine_to_coarse_from_refined(
43    pbsamp_to_fine: &[usize],
44    pbsamp_to_coarse: &[usize],
45    num_fine: usize,
46) -> Vec<usize> {
47    let mut mapping = vec![usize::MAX; num_fine];
48    for pbsamp in 0..pbsamp_to_fine.len() {
49        let f = pbsamp_to_fine[pbsamp];
50        if mapping[f] == usize::MAX {
51            mapping[f] = pbsamp_to_coarse[pbsamp];
52        } else {
53            debug_assert_eq!(
54                mapping[f], pbsamp_to_coarse[pbsamp],
55                "refinement broke hierarchy at fine group {}",
56                f
57            );
58        }
59    }
60    mapping
61}
62
63/// Per-level initial pb-sample → group, derived from the finest binary
64/// hash codes by bit-masking each level's sort dim and compacting labels to
65/// `0..k_level`. Each pb-sample's finest hash code is read from any of its
66/// member cells (all cells in a pb-sample share the same finest group).
67///
68/// `strata_bits` are low bits crossed into `fine_codes` for CNV purity;
69/// they are included in the mask width but do not inflate `level_dims`
70/// (expression budget stays `2^d`).
71pub(super) fn initial_per_level_from_hash(
72    fine_codes: &[usize],
73    pb_sample_to_cells: &[Vec<usize>],
74    level_dims: &[usize],
75    strata_bits: usize,
76) -> Vec<Vec<usize>> {
77    let num_pb = pb_sample_to_cells.len();
78    level_dims
79        .iter()
80        .map(|&d| {
81            let width = d.saturating_add(strata_bits);
82            let mask = if width >= usize::BITS as usize {
83                usize::MAX
84            } else {
85                (1_usize << width).wrapping_sub(1)
86            };
87            let codes: Vec<usize> = (0..num_pb)
88                .map(|pbsamp| fine_codes[pb_sample_to_cells[pbsamp][0]] & mask)
89                .collect();
90            crate::alg::refine_multilevel::compact_labels(&codes).0
91        })
92        .collect()
93}
94
95/// Per-level reprojection offsets consumed by `refine_assignments`: for each
96/// pb-sample, the finest hash bits *above* the parent level's sort dim
97/// (`code >> parent_dim`, masked to `child_dim − parent_dim` bits). Crossing
98/// the refined parent with these "extra bits" keeps each finer level bounded
99/// by `2^child_dim` while staying hash-meaningful (cells agreeing on the finer
100/// SVD dims group together), instead of an arbitrary positional index. The
101/// coarsest level has no parent, so its entry is empty (unused).
102///
103/// `strata_bits` shift the parent cut so low stratum bits are skipped when
104/// reading expression extras; the extra-bit width stays `child − parent`.
105pub(super) fn build_reproject_offsets(
106    fine_codes: &[usize],
107    pb_sample_to_cells: &[Vec<usize>],
108    level_dims: &[usize],
109    strata_bits: usize,
110) -> Vec<Vec<usize>> {
111    let raw: Vec<usize> = pb_sample_to_cells
112        .iter()
113        .map(|cells| fine_codes[cells[0]])
114        .collect();
115    (0..level_dims.len())
116        .map(|level| {
117            if level + 1 < level_dims.len() {
118                let parent_dim = level_dims[level + 1].saturating_add(strata_bits);
119                let nbits = level_dims[level].saturating_sub(level_dims[level + 1]);
120                let mask = if nbits >= usize::BITS as usize {
121                    usize::MAX
122                } else {
123                    (1_usize << nbits).wrapping_sub(1)
124                };
125                raw.iter().map(|&c| (c >> parent_dim) & mask).collect()
126            } else {
127                Vec::new()
128            }
129        })
130        .collect()
131}
132
133/// Run refinement when `allow_refine`, else return the compacted initial
134/// mapping unchanged (single-batch → no BBKNN candidates, nothing to refine).
135pub(super) fn refine_or_identity(
136    allow_refine: bool,
137    inputs: &crate::alg::refine_multilevel::RefineInputs<'_>,
138    refine_params: &crate::alg::refine_multilevel::RefineParams,
139) -> anyhow::Result<crate::alg::refine_multilevel::RefinedAssignment> {
140    if allow_refine {
141        crate::alg::refine_multilevel::refine_assignments(inputs, refine_params)
142    } else {
143        let mut pbsamp_to_group: Vec<Vec<usize>> =
144            Vec::with_capacity(inputs.initial_sc_to_group_per_level.len());
145        let mut num_groups_per_level =
146            Vec::with_capacity(inputs.initial_sc_to_group_per_level.len());
147        for lvl in inputs.initial_sc_to_group_per_level {
148            let (compact, k) = crate::alg::refine_multilevel::compact_labels(lvl);
149            num_groups_per_level.push(k);
150            pbsamp_to_group.push(compact);
151        }
152        Ok(crate::alg::refine_multilevel::RefinedAssignment {
153            pbsamp_to_group,
154            num_groups_per_level,
155        })
156    }
157}
158
159/// Append-only memory: give each anchored (carried) pb-sample its own
160/// finest group, appended after the groups of the ordinary pb-samples.
161///
162/// The refined partition still decides the finest groups of the NEW columns;
163/// carried columns are removed from those groups (a group left with only
164/// carried members disappears in the compaction) and instead keep their
165/// stored granularity as singleton groups, ordered by column index so a
166/// re-emitted reference preserves the parent's column order. Every finest
167/// stat for a singleton group then reduces to the carried column's own
168/// values — observed sum `y·w` over size `w` is the stored rate again — so
169/// carrying a reference through a round reproduces it instead of
170/// re-averaging it into (batch × group) blends, which would compound
171/// resolution loss every round. Coarser levels are left blended: they are
172/// transient training aids recomputed each round from the frozen finest
173/// columns, so averaging there does not compound.
174///
175/// Bulk pb-samples are singletons through the same mechanism (a bulk sample
176/// is already a summary and must never be re-averaged), so they get their own
177/// finest groups here too; what distinguishes them is the matching exclusion
178/// in `bbknn_match_one_pbsamp`, not the grouping.
179///
180/// Only level 0 is rewritten, and splitting a group cannot break the
181/// sibling-constrained hierarchy the coarser levels assume. Identity when the
182/// layout holds no singleton pb-samples — the layout is the sole owner of that
183/// membership (see [`PbSampleLayout::singleton_col`]).
184pub(super) fn split_anchored_finest_groups(
185    mut refined: crate::alg::refine_multilevel::RefinedAssignment,
186    layout: &PbSampleLayout,
187) -> crate::alg::refine_multilevel::RefinedAssignment {
188    // Anchored pb-samples are singletons; sort by their one column to pin the
189    // appended group order to the parent reference's column order.
190    let mut anchored: Vec<(usize, usize)> = layout
191        .singleton_col
192        .iter()
193        .enumerate()
194        .filter_map(|(p, col)| col.map(|c| (c, p)))
195        .collect();
196    if anchored.is_empty() {
197        return refined;
198    }
199    anchored.sort_unstable_by_key(|&(col, _)| col);
200
201    let finest = &mut refined.pbsamp_to_group[0];
202    let ordinary: Vec<usize> = layout
203        .singleton_col
204        .iter()
205        .zip(finest.iter())
206        .filter_map(|(a, &g)| a.is_none().then_some(g))
207        .collect();
208    let (compact, k_new) = crate::alg::refine_multilevel::compact_labels(&ordinary);
209    let mut compact = compact.into_iter();
210    for (p, a) in layout.singleton_col.iter().enumerate() {
211        if a.is_none() {
212            finest[p] = compact
213                .next()
214                .expect("one compacted label per ordinary pb-sample");
215        }
216    }
217    for (j, &(_, p)) in anchored.iter().enumerate() {
218        finest[p] = k_new + j;
219    }
220    refined.num_groups_per_level[0] = k_new + anchored.len();
221    info!(
222        "Append-only finest partition: {} new-data groups + {} carried + {} bulk singletons",
223        k_new,
224        anchored
225            .iter()
226            .filter(|&&(_, p)| !layout.is_bulk(p))
227            .count(),
228        anchored.iter().filter(|&&(_, p)| layout.is_bulk(p)).count(),
229    );
230    refined
231}
232
233/// Shared inputs to both the `SparseIoVec` and `SparseIoStack` refinement
234/// helpers. Keeps call-site signatures compact; every field is derivable
235/// from `MultilevelParams` + the finest-level hash partition.
236#[derive(Clone, Copy)]
237pub(super) struct RefineCollectCtx<'a> {
238    pub(super) fine_codes: &'a [usize],
239    pub(super) group_to_cols_finest: &'a [Vec<usize>],
240    pub(super) level_dims: &'a [usize],
241    pub(super) num_features: usize,
242    pub(super) num_batches: usize,
243    pub(super) knn: usize,
244    pub(super) opt_iter: usize,
245    pub(super) refine_params: &'a crate::alg::refine_multilevel::RefineParams,
246    /// Posterior planes the emitted `CollapsedOut` should carry (threaded
247    /// from `MultilevelParams::output_calibration`).
248    pub(super) output_calibration: legume_numeric::param::traits::CalibrateTarget,
249    /// Resolved anchor-batch indices — see `MultilevelParams::anchor_batches`.
250    pub(super) anchor_batches: Option<&'a [usize]>,
251    /// Batches whose columns are already summaries (carried reference, bulk)
252    /// and must stay singleton pb-samples. Distinct from `anchor_batches`,
253    /// which is the matching frame and may be ordinary cells.
254    pub(super) summary_batches: Option<&'a [usize]>,
255    /// Resolved bulk-batch indices — see `MultilevelParams::bulk_batches`.
256    pub(super) bulk_batches: Option<&'a [usize]>,
257    /// See `MultilevelParams::observe_panels`.
258    pub(super) observe_panels: bool,
259    /// See `MultilevelParams::keep_finest_stats`.
260    pub(super) keep_finest_stats: bool,
261    /// Tree behind `fine_codes`, when the finest partition was grown.
262    pub(super) pb_tree: Option<&'a PbTree>,
263    /// Per-cell CNV stratum when collapse is stratified (`None` = no filter).
264    pub(super) cell_to_stratum: Option<&'a [usize]>,
265    /// Exclude unmatched sample mass from the δ update (set when strata
266    /// are present so private-clone observed mass cannot pull δ).
267    pub(super) exclude_unmatched_from_delta: bool,
268    /// Low bits of `fine_codes` reserved for stratum (0 when unstratified).
269    /// Hash/reproject masks include these; `level_dims` do not.
270    pub(super) strata_bits: usize,
271}
272
273/// Refinement integration path for `SparseIoVec`.
274///
275/// Walks each level of the hash-initialized hierarchy, runs
276/// `refine_multilevel::refine_assignments` over pb-samples, then rebuilds
277/// `CollapsedStat` per level from the refined cell → group assignment and
278/// emits `CollapsedOut` with identical shape to the legacy path. Also
279/// surfaces the per-level cell → pb mapping (finest-first, matching
280/// `levels`) so consumers — e.g. `graph-embedding-util`'s nested chain
281/// sampler — can build pb-tree parent/child maps without rerunning the
282/// collapse internals.
283pub(super) fn refine_and_collect_single_layer(
284    data_vec: &mut SparseIoVec,
285    proj_kn: &DMatrix<f32>,
286    ctx: &RefineCollectCtx<'_>,
287) -> anyhow::Result<MultilevelCollapseOut> {
288    let RefineCollectCtx {
289        fine_codes,
290        group_to_cols_finest: _,
291        level_dims,
292        num_features,
293        num_batches,
294        knn,
295        opt_iter,
296        refine_params,
297        output_calibration,
298        anchor_batches: _,
299        summary_batches: _,
300        bulk_batches: _,
301        observe_panels: _,
302        keep_finest_stats: _,
303        pb_tree: _,
304        cell_to_stratum: _,
305        exclude_unmatched_from_delta: _,
306        strata_bits,
307    } = *ctx;
308    info!(
309        "Multi-level refinement path (BBKNN + DC-SBM): {} levels",
310        level_dims.len()
311    );
312
313    // 1. Build pb-samples (layout + gene sums) from the finest partition.
314    let pb_samples = build_pb_samples(
315        data_vec,
316        proj_kn,
317        num_features,
318        ctx.summary_batches.unwrap_or(&[]),
319        ctx.bulk_batches.unwrap_or(&[]),
320        ctx.cell_to_stratum,
321    )?;
322    let num_pb = pb_samples.layout.cell_counts.len();
323    let ncells_dbg = proj_kn.ncols();
324    info!(
325        "Built {} pb-samples from {} cells (ratio {:.2}; knn={})",
326        num_pb,
327        ncells_dbg,
328        num_pb as f32 / ncells_dbg.max(1) as f32,
329        knn
330    );
331    if num_pb as f32 > 0.8 * ncells_dbg as f32 {
332        warn!(
333            "pb-sample count ({}) is close to cell count ({}) — hash partition is too fine \
334             (many 1-cell pb-samples). Consider lowering --sort-dim.",
335            num_pb, ncells_dbg
336        );
337    }
338
339    // 2. pbsamp → cells, via the layout's own column mapping.
340    let ncols = proj_kn.ncols();
341    let pb_sample_to_cells = build_pb_sample_to_cells(&pb_samples.layout);
342
343    let initial_per_level =
344        initial_per_level_from_hash(fine_codes, &pb_sample_to_cells, level_dims, strata_bits);
345    let empty: [ColumnDict<usize>; 0] = [];
346    let batch_knn: &[ColumnDict<usize>] = if num_batches >= 2 {
347        data_vec
348            .batch_knn_lookup()
349            .ok_or_else(|| anyhow::anyhow!("batch_knn_lookup not built"))?
350            .as_slice()
351    } else {
352        &empty
353    };
354    let reproject_offsets =
355        build_reproject_offsets(fine_codes, &pb_sample_to_cells, level_dims, strata_bits);
356    let inputs = crate::alg::refine_multilevel::RefineInputs {
357        layout: &pb_samples.layout,
358        gene_sums: &pb_samples.gene_sums,
359        num_genes: num_features,
360        pb_sample_to_cells: &pb_sample_to_cells,
361        batch_knn_lookup: batch_knn,
362        k_per_batch: knn,
363        initial_sc_to_group_per_level: &initial_per_level,
364        reproject_offsets_per_level: &reproject_offsets,
365    };
366    let refined = split_anchored_finest_groups(
367        refine_or_identity(num_batches >= 2, &inputs, refine_params)?,
368        &pb_samples.layout,
369    );
370
371    ///////////////////////////////////
372    // collapse-structure diagnostic //
373    ///////////////////////////////////
374    // Resolve how the finest column count actually arises: distinct leaf
375    // codes (initial finest groups) vs refined finest groups, and whether a
376    // refined finest group spans multiple batches (i.e. batches are merged
377    // within a group) or stays single-batch (a per-(leaf,batch) column).
378    {
379        let n_leaves = initial_per_level
380            .first()
381            .map(|lvl| lvl.iter().copied().max().map_or(0, |m| m + 1))
382            .unwrap_or(0);
383        let finest = &refined.pbsamp_to_group[0];
384        let k_fin = refined.num_groups_per_level[0];
385        // Bitmask of batches per finest group (handles up to 128 batches).
386        let mut batch_mask = vec![0u128; k_fin];
387        let b2g = &pb_samples.layout.pb_sample_to_batch;
388        for (pb, &g) in finest.iter().enumerate() {
389            let b = b2g[pb];
390            if b < 128 {
391                batch_mask[g] |= 1u128 << b;
392            }
393        }
394        let spans: Vec<u32> = batch_mask.iter().map(|m| m.count_ones()).collect();
395        let multi = spans.iter().filter(|&&c| c > 1).count();
396        let max_b = spans.iter().copied().max().unwrap_or(0);
397        let mean_b = spans.iter().map(|&c| c as f64).sum::<f64>() / k_fin.max(1) as f64;
398        info!(
399            "collapse structure: {} pb-samples, {} batches, {} leaf codes (finest init), \
400             {} refined finest groups; finest groups spanning >1 batch: {}/{} \
401             (max {} batches/group, mean {:.2})",
402            num_pb, num_batches, n_leaves, k_fin, multi, k_fin, max_b, mean_b
403        );
404    }
405
406    // 5. Build finest CollapsedStat once from a full data pass, then derive
407    //    coarser levels by `merge_stat` on column-aggregated sums — avoids
408    //    re-reading all cells at every level (matches legacy merge descent).
409    let num_levels = level_dims.len();
410    let k_finest = refined.num_groups_per_level[0];
411    let mut cell_to_group_finest = vec![0usize; ncols];
412    for (pbsamp, cells) in pb_sample_to_cells.iter().enumerate() {
413        let g = refined.pbsamp_to_group[0][pbsamp];
414        for &c in cells {
415            cell_to_group_finest[c] = g;
416        }
417    }
418    let finest_str = pad_numeric_labels(&cell_to_group_finest, k_finest);
419    let nthreads = rayon::current_num_threads();
420    info!(
421        "Assigning {} cells to {} finest pb-sample groups ({} rayon threads) ...",
422        ncols, k_finest, nthreads
423    );
424    data_vec.assign_groups(&finest_str, None);
425    debug_assert_eq!(data_vec.num_groups(), k_finest);
426
427    let mut fine_stat = CollapsedStat::new(num_features, k_finest, num_batches);
428    fine_stat.exclude_unmatched_from_delta = ctx.exclude_unmatched_from_delta;
429    info!("Collecting basic stats over {} groups ...", k_finest);
430    data_vec.collect_basic_stat(&mut fine_stat)?;
431    if num_batches >= 2 {
432        info!(
433            "Collecting per-batch stats over {} groups × {} batches ...",
434            k_finest, num_batches
435        );
436        data_vec.collect_batch_stat(&mut fine_stat)?;
437        let batch_knn = data_vec
438            .batch_knn_lookup()
439            .ok_or_else(|| anyhow::anyhow!("batch_knn_lookup not built"))?;
440        info!(
441            "Collecting cross-batch matched stats (knn={}) over {} pb-samples ...",
442            knn, num_pb
443        );
444        collect_matched_stat_coarse(
445            &pb_samples.layout,
446            &pb_samples.gene_sums,
447            &refined.pbsamp_to_group[0],
448            batch_knn.as_slice(),
449            knn,
450            ctx.anchor_batches,
451            &mut fine_stat,
452        )?;
453    }
454
455    info!(
456        "Level 1/{}: refined k={} (finest; {} cells read)",
457        num_levels, k_finest, ncols
458    );
459    if ctx.observe_panels {
460        attach_observability(&mut fine_stat, data_vec)?;
461    }
462
463    let mut results: Vec<CollapsedOut> = Vec::with_capacity(num_levels);
464    let finest_out = optimize(
465        &fine_stat,
466        (1.0, 1.0),
467        opt_iter,
468        &format!("Fit L1/{}", num_levels),
469        output_calibration,
470        ctx.keep_finest_stats,
471    )?;
472    results.push(finest_out);
473
474    let mut prev_stat = fine_stat;
475    for level in 1..num_levels {
476        let k_prev = refined.num_groups_per_level[level - 1];
477        let k_level = refined.num_groups_per_level[level];
478        let fine_to_coarse = fine_to_coarse_from_refined(
479            &refined.pbsamp_to_group[level - 1],
480            &refined.pbsamp_to_group[level],
481            k_prev,
482        );
483        let coarse_stat = merge_stat(&prev_stat, &fine_to_coarse, k_level);
484        info!(
485            "Level {}/{}: refined k={} (merged from {})",
486            level + 1,
487            num_levels,
488            k_level,
489            k_prev
490        );
491        let level_opt_iter = (opt_iter / 2).max(10);
492        let out = optimize(
493            &coarse_stat,
494            (1.0, 1.0),
495            level_opt_iter,
496            &format!("Fit L{}/{}", level + 1, num_levels),
497            output_calibration,
498            false,
499        )?;
500        results.push(out);
501        prev_stat = coarse_stat;
502    }
503    info!(
504        "Fitted pseudobulk posteriors for {} refined levels: k = {:?} (finest first)",
505        num_levels, refined.num_groups_per_level
506    );
507
508    let cell_to_pb_per_level = cell_to_pb_per_level(&refined, &pb_sample_to_cells, ncols);
509
510    // Tree: attach the finest pb ids behind every leaf code.
511    let pb_tree = ctx.pb_tree.map(|tree| {
512        let mut tree = tree.clone();
513        let mut leaf_pbs: HashMap<usize, std::collections::BTreeSet<usize>> = HashMap::default();
514        if let Some(finest) = cell_to_pb_per_level.first() {
515            for (c, &code) in fine_codes.iter().enumerate() {
516                leaf_pbs.entry(code).or_default().insert(finest[c]);
517            }
518        }
519        let mut leaves: Vec<(usize, Vec<usize>)> = leaf_pbs
520            .into_iter()
521            .map(|(code, pbs)| (code, pbs.into_iter().collect()))
522            .collect();
523        leaves.sort_by_key(|x| x.0);
524        tree.leaf_to_finest_pb = leaves;
525        tree
526    });
527
528    Ok(MultilevelCollapseOut {
529        levels: results,
530        cell_to_pb_per_level,
531        pb_tree,
532    })
533}
534
535/// Per-level cell → pb mapping (finest-first), walking each level's
536/// `pbsamp_to_group` through `pb_sample_to_cells`.
537fn cell_to_pb_per_level(
538    refined: &crate::alg::refine_multilevel::RefinedAssignment,
539    pb_sample_to_cells: &[Vec<usize>],
540    ncols: usize,
541) -> Vec<Vec<usize>> {
542    refined
543        .pbsamp_to_group
544        .iter()
545        .map(|groups| {
546            let mut c2g = vec![0usize; ncols];
547            for (pbsamp, cells) in pb_sample_to_cells.iter().enumerate() {
548                for &c in cells {
549                    c2g[c] = groups[pbsamp];
550                }
551            }
552            c2g
553        })
554        .collect()
555}
556
557/// What the stack collapse returns: per level (finest first), one
558/// `CollapsedOut` per layer, and the per-level cell → pb membership the layers
559/// share.
560pub struct StackCollapseOut {
561    pub levels: Vec<Vec<CollapsedOut>>,
562    pub cell_to_pb_per_level: Vec<Vec<usize>>,
563}
564
565/// Refinement integration path for `SparseIoStack`.
566///
567/// Shares one `RefinedAssignment` across all layers (first-layer-owns the
568/// grouping decision, matching the existing stack convention). Per level ×
569/// layer we rebuild `CollapsedStat` and emit `CollapsedOut`.
570pub(super) fn refine_and_collect_stack(
571    stack: &mut SparseIoStack,
572    proj_kn: &DMatrix<f32>,
573    ctx: &RefineCollectCtx<'_>,
574) -> anyhow::Result<StackCollapseOut> {
575    let RefineCollectCtx {
576        fine_codes,
577        group_to_cols_finest,
578        level_dims,
579        num_features: _,
580        num_batches,
581        knn,
582        opt_iter,
583        refine_params,
584        output_calibration,
585        anchor_batches: _,
586        summary_batches: _,
587        bulk_batches: _,
588        observe_panels: _,
589        keep_finest_stats,
590        pb_tree: _,
591        cell_to_stratum,
592        exclude_unmatched_from_delta,
593        strata_bits,
594    } = *ctx;
595    let num_layers = stack.num_types();
596    info!(
597        "Multi-level stack refinement (BBKNN + DC-SBM): {} layers × {} levels",
598        num_layers,
599        level_dims.len()
600    );
601
602    let ncols = proj_kn.ncols();
603    let col_to_batch: Vec<usize> = stack.stack[0].get_batch_membership(0..ncols);
604
605    // Build shared pb-sample layout from layer[0]'s row count and the shared
606    // projection. The layout only uses `proj_kn` + grouping, no raw reads.
607    let layout = build_pb_sample_layout(
608        group_to_cols_finest,
609        &col_to_batch,
610        proj_kn,
611        None,
612        &[],
613        &[],
614        cell_to_stratum,
615    )?;
616    let num_pb = layout.cell_counts.len();
617
618    // Gene sums for layer[0] drive the refinement (first-layer-owns).
619    let owner_num_features = stack.stack[0].num_rows();
620    let gene_sums_owner = collect_pb_sample_gene_sums(
621        &stack.stack[0],
622        group_to_cols_finest,
623        &layout.cell_to_pbsamp,
624        num_pb,
625    )?;
626
627    let pb_sample_to_cells = build_pb_sample_to_cells(&layout);
628
629    let initial_per_level =
630        initial_per_level_from_hash(fine_codes, &pb_sample_to_cells, level_dims, strata_bits);
631    let empty: [ColumnDict<usize>; 0] = [];
632    let batch_knn: &[ColumnDict<usize>] = if num_batches >= 2 {
633        stack.stack[0]
634            .batch_knn_lookup()
635            .ok_or_else(|| anyhow::anyhow!("batch_knn_lookup not built"))?
636            .as_slice()
637    } else {
638        &empty
639    };
640    let reproject_offsets =
641        build_reproject_offsets(fine_codes, &pb_sample_to_cells, level_dims, strata_bits);
642    let inputs = crate::alg::refine_multilevel::RefineInputs {
643        layout: &layout,
644        gene_sums: &gene_sums_owner,
645        num_genes: owner_num_features,
646        pb_sample_to_cells: &pb_sample_to_cells,
647        batch_knn_lookup: batch_knn,
648        k_per_batch: knn,
649        initial_sc_to_group_per_level: &initial_per_level,
650        reproject_offsets_per_level: &reproject_offsets,
651    };
652    let refined = refine_or_identity(num_batches >= 2, &inputs, refine_params)?;
653
654    // Per-layer gene_sums for the remaining layers (layer 0 reuses `gene_sums_owner`).
655    let mut per_layer_gene_sums: Vec<GeneSums> = Vec::with_capacity(num_layers);
656    for (d, layer) in stack.stack.iter().enumerate() {
657        if d == 0 {
658            per_layer_gene_sums.push(gene_sums_owner.clone());
659        } else {
660            per_layer_gene_sums.push(collect_pb_sample_gene_sums(
661                layer,
662                group_to_cols_finest,
663                &layout.cell_to_pbsamp,
664                num_pb,
665            )?);
666        }
667    }
668
669    // Finest CollapsedStat per layer via a single data pass, then descend
670    //    into coarser levels by `merge_stat` on column aggregates.
671    let num_levels = level_dims.len();
672    let k_finest = refined.num_groups_per_level[0];
673    let mut cell_to_group_finest = vec![0usize; ncols];
674    for (pbsamp, cells) in pb_sample_to_cells.iter().enumerate() {
675        let g = refined.pbsamp_to_group[0][pbsamp];
676        for &c in cells {
677            cell_to_group_finest[c] = g;
678        }
679    }
680    let finest_str = pad_numeric_labels(&cell_to_group_finest, k_finest);
681    let nthreads = rayon::current_num_threads();
682    info!(
683        "Assigning {} cells to {} finest pb-sample groups across {} layers ({} rayon threads) ...",
684        ncols, k_finest, num_layers, nthreads
685    );
686    for layer in stack.stack.iter_mut() {
687        layer.assign_groups(&finest_str, None);
688    }
689
690    let mut fine_stats: Vec<CollapsedStat> = Vec::with_capacity(num_layers);
691    let mut finest_layer_results = Vec::with_capacity(num_layers);
692    for (d, layer) in stack.stack.iter().enumerate() {
693        let num_features = layer.num_rows();
694        let mut stat = CollapsedStat::new(num_features, k_finest, num_batches);
695        stat.exclude_unmatched_from_delta = exclude_unmatched_from_delta;
696        info!(
697            "Layer {}/{}: collecting basic stats over {} groups ...",
698            d + 1,
699            num_layers,
700            k_finest
701        );
702        layer.collect_basic_stat(&mut stat)?;
703        if num_batches >= 2 {
704            info!(
705                "Layer {}/{}: collecting per-batch stats ({} batches) ...",
706                d + 1,
707                num_layers,
708                num_batches
709            );
710            layer.collect_batch_stat(&mut stat)?;
711            let batch_knn = layer
712                .batch_knn_lookup()
713                .ok_or_else(|| anyhow::anyhow!("batch_knn_lookup not built"))?;
714            info!(
715                "Layer {}/{}: collecting cross-batch matched stats (knn={}) over {} pb-samples ...",
716                d + 1,
717                num_layers,
718                knn,
719                num_pb
720            );
721            collect_matched_stat_coarse(
722                &layout,
723                &per_layer_gene_sums[d],
724                &refined.pbsamp_to_group[0],
725                batch_knn.as_slice(),
726                knn,
727                ctx.anchor_batches,
728                &mut stat,
729            )?;
730        }
731        let out = optimize(
732            &stat,
733            (1.0, 1.0),
734            opt_iter,
735            &format!("Fit L1/{} layer {}/{}", num_levels, d + 1, num_layers),
736            output_calibration,
737            keep_finest_stats,
738        )?;
739        finest_layer_results.push(out);
740        fine_stats.push(stat);
741    }
742    info!(
743        "Level 1/{}: refined k={} (finest; {} layers × {} cells)",
744        num_levels, k_finest, num_layers, ncols
745    );
746    let mut results: Vec<Vec<CollapsedOut>> = Vec::with_capacity(num_levels);
747    results.push(finest_layer_results);
748
749    let mut prev_stats = fine_stats;
750    for level in 1..num_levels {
751        let k_prev = refined.num_groups_per_level[level - 1];
752        let k_level = refined.num_groups_per_level[level];
753        let fine_to_coarse = fine_to_coarse_from_refined(
754            &refined.pbsamp_to_group[level - 1],
755            &refined.pbsamp_to_group[level],
756            k_prev,
757        );
758        let level_opt_iter = (opt_iter / 2).max(10);
759        let mut layer_results = Vec::with_capacity(num_layers);
760        let mut coarse_stats = Vec::with_capacity(num_layers);
761        for (d, prev_stat) in prev_stats.iter().enumerate() {
762            let coarse_stat = merge_stat(prev_stat, &fine_to_coarse, k_level);
763            let out = optimize(
764                &coarse_stat,
765                (1.0, 1.0),
766                level_opt_iter,
767                &format!(
768                    "Fit L{}/{} layer {}/{}",
769                    level + 1,
770                    num_levels,
771                    d + 1,
772                    num_layers
773                ),
774                output_calibration,
775                false,
776            )?;
777            layer_results.push(out);
778            coarse_stats.push(coarse_stat);
779        }
780        info!(
781            "Level {}/{}: refined k={} (merged from {}, {} layers)",
782            level + 1,
783            num_levels,
784            k_level,
785            k_prev,
786            num_layers
787        );
788        results.push(layer_results);
789        prev_stats = coarse_stats;
790    }
791
792    Ok(StackCollapseOut {
793        levels: results,
794        cell_to_pb_per_level: cell_to_pb_per_level(&refined, &pb_sample_to_cells, ncols),
795    })
796}
797
798/// Compute sort dimensions for each level, linearly spaced from
799/// finest to coarsest (fine→coarse). Duplicate dimensions are
800/// removed so that extra levels don't repeat the same partitioning.
801pub(super) fn compute_level_sort_dims(finest_sort_dim: usize, num_levels: usize) -> Vec<usize> {
802    if num_levels <= 1 {
803        return vec![finest_sort_dim];
804    }
805    let coarsest = DEFAULT_COARSEST_SORT_DIM.min(finest_sort_dim);
806    let mut dims = Vec::with_capacity(num_levels);
807    for level in 0..num_levels {
808        // t goes from 0 (finest) to 1 (coarsest)
809        let t = level as f32 / (num_levels - 1) as f32;
810        let dim = finest_sort_dim as f32 - t * (finest_sort_dim - coarsest) as f32;
811        let dim = dim.round() as usize;
812        if dims.last() != Some(&dim) {
813            dims.push(dim);
814        }
815    }
816    dims
817}
818
819#[cfg(test)]
820#[path = "refine_tests.rs"]
821mod reproject_tests;