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).
135///
136/// `pooled_matches` is the pooled cross-batch match table of `inputs` when the
137/// caller already holds it (see [`cross_batch_matches`]).
138pub(super) fn refine_or_identity(
139    allow_refine: bool,
140    inputs: &crate::alg::refine_multilevel::RefineInputs<'_>,
141    refine_params: &crate::alg::refine_multilevel::RefineParams,
142    pooled_matches: Option<&[Vec<(usize, f32)>]>,
143) -> anyhow::Result<crate::alg::refine_multilevel::RefinedAssignment> {
144    if allow_refine {
145        crate::alg::refine_multilevel::refine_assignments_with_matches(
146            inputs,
147            refine_params,
148            pooled_matches,
149        )
150    } else {
151        let mut pbsamp_to_group: Vec<Vec<usize>> =
152            Vec::with_capacity(inputs.initial_sc_to_group_per_level.len());
153        let mut num_groups_per_level =
154            Vec::with_capacity(inputs.initial_sc_to_group_per_level.len());
155        for lvl in inputs.initial_sc_to_group_per_level {
156            let (compact, k) = crate::alg::refine_multilevel::compact_labels(lvl);
157            num_groups_per_level.push(k);
158            pbsamp_to_group.push(compact);
159        }
160        Ok(crate::alg::refine_multilevel::RefinedAssignment {
161            pbsamp_to_group,
162            num_groups_per_level,
163        })
164    }
165}
166
167/// The cross-batch match tables one collapse needs, each computed once:
168/// `pooled` (every pb-sample against every other batch) feeds the refinement's
169/// candidate sets and, without anchors, the matched statistics; `anchored`
170/// (every pb-sample against the anchor batches) feeds the matched statistics
171/// when there are anchors.
172pub(super) struct CrossBatchMatches {
173    pooled: Option<Vec<Vec<(usize, f32)>>>,
174    anchored: Option<Vec<Vec<(usize, f32)>>>,
175}
176
177impl CrossBatchMatches {
178    /// The table the refinement proposes moves from.
179    pub(super) fn for_refinement(&self) -> Option<&[Vec<(usize, f32)>]> {
180        self.pooled.as_deref()
181    }
182
183    /// The table the counterfactual is drawn from; `None` on a single batch.
184    pub(super) fn for_matched_stat(&self) -> Option<&[Vec<(usize, f32)>]> {
185        self.anchored.as_deref().or(self.pooled.as_deref())
186    }
187}
188
189/// Match the pb-samples of `layout` across batches for a collapse over
190/// `batch_knn_lookup` (fewer than two batches: no tables). The pooled table
191/// is skipped when nothing reads it — anchors source the counterfactual and
192/// `refine_params` asks for no refinement sweep.
193pub(super) fn cross_batch_matches(
194    layout: &PbSampleLayout,
195    batch_knn_lookup: &[ColumnDict<usize>],
196    knn: usize,
197    anchor_batches: Option<&[usize]>,
198    refine_params: &crate::alg::refine_multilevel::RefineParams,
199) -> anyhow::Result<CrossBatchMatches> {
200    if batch_knn_lookup.len() < 2 {
201        return Ok(CrossBatchMatches {
202            pooled: None,
203            anchored: None,
204        });
205    }
206    let refines = refine_params.num_gibbs > 0 || refine_params.num_greedy > 0;
207    let pooled = (refines || anchor_batches.is_none())
208        .then(|| per_batch_sc_neighbors(layout, batch_knn_lookup, knn, None))
209        .transpose()?;
210    let anchored = anchor_batches
211        .map(|anchors| per_batch_sc_neighbors(layout, batch_knn_lookup, knn, Some(anchors)))
212        .transpose()?;
213    Ok(CrossBatchMatches { pooled, anchored })
214}
215
216/// Append-only memory: give each anchored (carried) pb-sample its own
217/// finest group, appended after the groups of the ordinary pb-samples.
218///
219/// The refined partition still decides the finest groups of the NEW columns;
220/// carried columns are removed from those groups (a group left with only
221/// carried members disappears in the compaction) and instead keep their
222/// stored granularity as singleton groups, ordered by column index so a
223/// re-emitted reference preserves the parent's column order. Every finest
224/// stat for a singleton group then reduces to the carried column's own
225/// values — observed sum `y·w` over size `w` is the stored rate again — so
226/// carrying a reference through a round reproduces it instead of
227/// re-averaging it into (batch × group) blends, which would compound
228/// resolution loss every round. Coarser levels are left blended: they are
229/// transient training aids recomputed each round from the frozen finest
230/// columns, so averaging there does not compound.
231///
232/// Bulk pb-samples are singletons through the same mechanism (a bulk sample
233/// is already a summary and must never be re-averaged), so they get their own
234/// finest groups here too; what distinguishes them is the matching exclusion
235/// in `bbknn_match_one_pbsamp`, not the grouping.
236///
237/// Only level 0 is rewritten, and splitting a group cannot break the
238/// sibling-constrained hierarchy the coarser levels assume. Identity when the
239/// layout holds no singleton pb-samples — the layout is the sole owner of that
240/// membership (see [`PbSampleLayout::singleton_col`]).
241pub(super) fn split_anchored_finest_groups(
242    mut refined: crate::alg::refine_multilevel::RefinedAssignment,
243    layout: &PbSampleLayout,
244) -> crate::alg::refine_multilevel::RefinedAssignment {
245    // Anchored pb-samples are singletons; sort by their one column to pin the
246    // appended group order to the parent reference's column order.
247    let mut anchored: Vec<(usize, usize)> = layout
248        .singleton_col
249        .iter()
250        .enumerate()
251        .filter_map(|(p, col)| col.map(|c| (c, p)))
252        .collect();
253    if anchored.is_empty() {
254        return refined;
255    }
256    anchored.sort_unstable_by_key(|&(col, _)| col);
257
258    let finest = &mut refined.pbsamp_to_group[0];
259    let ordinary: Vec<usize> = layout
260        .singleton_col
261        .iter()
262        .zip(finest.iter())
263        .filter_map(|(a, &g)| a.is_none().then_some(g))
264        .collect();
265    let (compact, k_new) = crate::alg::refine_multilevel::compact_labels(&ordinary);
266    let mut compact = compact.into_iter();
267    for (p, a) in layout.singleton_col.iter().enumerate() {
268        if a.is_none() {
269            finest[p] = compact
270                .next()
271                .expect("one compacted label per ordinary pb-sample");
272        }
273    }
274    for (j, &(_, p)) in anchored.iter().enumerate() {
275        finest[p] = k_new + j;
276    }
277    refined.num_groups_per_level[0] = k_new + anchored.len();
278    info!(
279        "Append-only finest partition: {} new-data groups + {} carried + {} bulk singletons",
280        k_new,
281        anchored
282            .iter()
283            .filter(|&&(_, p)| !layout.is_bulk(p))
284            .count(),
285        anchored.iter().filter(|&&(_, p)| layout.is_bulk(p)).count(),
286    );
287    refined
288}
289
290/// Shared inputs to both the `SparseIoVec` and `SparseIoStack` refinement
291/// helpers. Keeps call-site signatures compact; every field is derivable
292/// from `MultilevelParams` + the finest-level hash partition.
293#[derive(Clone, Copy)]
294pub(super) struct RefineCollectCtx<'a> {
295    pub(super) fine_codes: &'a [usize],
296    pub(super) group_to_cols_finest: &'a [Vec<usize>],
297    pub(super) level_dims: &'a [usize],
298    pub(super) num_features: usize,
299    pub(super) num_batches: usize,
300    pub(super) knn: usize,
301    pub(super) opt_iter: usize,
302    pub(super) refine_params: &'a crate::alg::refine_multilevel::RefineParams,
303    /// Posterior planes the emitted `CollapsedOut` should carry (threaded
304    /// from `MultilevelParams::output_calibration`).
305    pub(super) output_calibration: legume_numeric::param::traits::CalibrateTarget,
306    /// Resolved anchor-batch indices — see `MultilevelParams::anchor_batches`.
307    pub(super) anchor_batches: Option<&'a [usize]>,
308    /// Batches whose columns are already summaries (carried reference, bulk)
309    /// and must stay singleton pb-samples. Distinct from `anchor_batches`,
310    /// which is the matching frame and may be ordinary cells.
311    pub(super) summary_batches: Option<&'a [usize]>,
312    /// Resolved bulk-batch indices — see `MultilevelParams::bulk_batches`.
313    pub(super) bulk_batches: Option<&'a [usize]>,
314    /// See `MultilevelParams::observe_panels`.
315    pub(super) observe_panels: bool,
316    /// See `MultilevelParams::keep_finest_stats`.
317    pub(super) keep_finest_stats: bool,
318    /// Tree behind `fine_codes`, when the finest partition was grown.
319    pub(super) pb_tree: Option<&'a PbTree>,
320    /// Per-cell CNV stratum when collapse is stratified (`None` = no filter).
321    pub(super) cell_to_stratum: Option<&'a [usize]>,
322    /// Exclude unmatched sample mass from the δ update (set when strata
323    /// are present so private-clone observed mass cannot pull δ).
324    pub(super) exclude_unmatched_from_delta: bool,
325    /// Low bits of `fine_codes` reserved for stratum (0 when unstratified).
326    /// Hash/reproject masks include these; `level_dims` do not.
327    pub(super) strata_bits: usize,
328}
329
330/// Refinement integration path for `SparseIoVec`.
331///
332/// Walks each level of the hash-initialized hierarchy, runs
333/// `refine_multilevel::refine_assignments` over pb-samples, then rebuilds
334/// `CollapsedStat` per level from the refined cell → group assignment and
335/// emits `CollapsedOut` with identical shape to the legacy path. Also
336/// surfaces the per-level cell → pb mapping (finest-first, matching
337/// `levels`) so consumers — e.g. `graph-embedding-util`'s nested chain
338/// sampler — can build pb-tree parent/child maps without rerunning the
339/// collapse internals.
340pub(super) fn refine_and_collect_single_layer(
341    data_vec: &mut SparseIoVec,
342    proj_kn: &DMatrix<f32>,
343    ctx: &RefineCollectCtx<'_>,
344) -> anyhow::Result<MultilevelCollapseOut> {
345    let RefineCollectCtx {
346        fine_codes,
347        group_to_cols_finest: _,
348        level_dims,
349        num_features,
350        num_batches,
351        knn,
352        opt_iter,
353        refine_params,
354        output_calibration,
355        anchor_batches: _,
356        summary_batches: _,
357        bulk_batches: _,
358        observe_panels: _,
359        keep_finest_stats: _,
360        pb_tree: _,
361        cell_to_stratum: _,
362        exclude_unmatched_from_delta: _,
363        strata_bits,
364    } = *ctx;
365    info!(
366        "Multi-level refinement path (BBKNN + DC-SBM): {} levels",
367        level_dims.len()
368    );
369
370    // 1. Build pb-samples (layout + gene sums) from the finest partition.
371    let pb_samples = build_pb_samples(
372        data_vec,
373        proj_kn,
374        num_features,
375        ctx.summary_batches.unwrap_or(&[]),
376        ctx.bulk_batches.unwrap_or(&[]),
377        ctx.cell_to_stratum,
378    )?;
379    let num_pb = pb_samples.layout.cell_counts.len();
380    let ncells_dbg = proj_kn.ncols();
381    info!(
382        "Built {} pb-samples from {} cells (ratio {:.2}; knn={})",
383        num_pb,
384        ncells_dbg,
385        num_pb as f32 / ncells_dbg.max(1) as f32,
386        knn
387    );
388    if num_pb as f32 > 0.8 * ncells_dbg as f32 {
389        warn!(
390            "pb-sample count ({}) is close to cell count ({}) — hash partition is too fine \
391             (many 1-cell pb-samples). Consider lowering --sort-dim.",
392            num_pb, ncells_dbg
393        );
394    }
395
396    // 2. pbsamp → cells, via the layout's own column mapping.
397    let ncols = proj_kn.ncols();
398    let pb_sample_to_cells = build_pb_sample_to_cells(&pb_samples.layout);
399
400    let initial_per_level =
401        initial_per_level_from_hash(fine_codes, &pb_sample_to_cells, level_dims, strata_bits);
402    let empty: [ColumnDict<usize>; 0] = [];
403    let batch_knn: &[ColumnDict<usize>] = if num_batches >= 2 {
404        data_vec
405            .batch_knn_lookup()
406            .ok_or_else(|| anyhow::anyhow!("batch_knn_lookup not built"))?
407            .as_slice()
408    } else {
409        &empty
410    };
411    let reproject_offsets =
412        build_reproject_offsets(fine_codes, &pb_sample_to_cells, level_dims, strata_bits);
413    let inputs = crate::alg::refine_multilevel::RefineInputs {
414        layout: &pb_samples.layout,
415        gene_sums: &pb_samples.gene_sums,
416        num_genes: num_features,
417        pb_sample_to_cells: &pb_sample_to_cells,
418        batch_knn_lookup: batch_knn,
419        k_per_batch: knn,
420        initial_sc_to_group_per_level: &initial_per_level,
421        reproject_offsets_per_level: &reproject_offsets,
422    };
423    let matches = cross_batch_matches(
424        &pb_samples.layout,
425        batch_knn,
426        knn,
427        ctx.anchor_batches,
428        refine_params,
429    )?;
430    let refined = split_anchored_finest_groups(
431        refine_or_identity(
432            num_batches >= 2,
433            &inputs,
434            refine_params,
435            matches.for_refinement(),
436        )?,
437        &pb_samples.layout,
438    );
439
440    ///////////////////////////////////
441    // collapse-structure diagnostic //
442    ///////////////////////////////////
443    // Resolve how the finest column count actually arises: distinct leaf
444    // codes (initial finest groups) vs refined finest groups, and whether a
445    // refined finest group spans multiple batches (i.e. batches are merged
446    // within a group) or stays single-batch (a per-(leaf,batch) column).
447    {
448        let n_leaves = initial_per_level
449            .first()
450            .map(|lvl| lvl.iter().copied().max().map_or(0, |m| m + 1))
451            .unwrap_or(0);
452        let finest = &refined.pbsamp_to_group[0];
453        let k_fin = refined.num_groups_per_level[0];
454        // Bitmask of batches per finest group (handles up to 128 batches).
455        let mut batch_mask = vec![0u128; k_fin];
456        let b2g = &pb_samples.layout.pb_sample_to_batch;
457        for (pb, &g) in finest.iter().enumerate() {
458            let b = b2g[pb];
459            if b < 128 {
460                batch_mask[g] |= 1u128 << b;
461            }
462        }
463        let spans: Vec<u32> = batch_mask.iter().map(|m| m.count_ones()).collect();
464        let multi = spans.iter().filter(|&&c| c > 1).count();
465        let max_b = spans.iter().copied().max().unwrap_or(0);
466        let mean_b = spans.iter().map(|&c| c as f64).sum::<f64>() / k_fin.max(1) as f64;
467        info!(
468            "collapse structure: {} pb-samples, {} batches, {} leaf codes (finest init), \
469             {} refined finest groups; finest groups spanning >1 batch: {}/{} \
470             (max {} batches/group, mean {:.2})",
471            num_pb, num_batches, n_leaves, k_fin, multi, k_fin, max_b, mean_b
472        );
473    }
474
475    // 5. Build finest CollapsedStat once from the pb-sample aggregates (the
476    //    data was read when those were summed), then derive coarser levels by
477    //    `merge_stat` on column-aggregated sums.
478    let num_levels = level_dims.len();
479    let k_finest = refined.num_groups_per_level[0];
480    let mut cell_to_group_finest = vec![0usize; ncols];
481    for (pbsamp, cells) in pb_sample_to_cells.iter().enumerate() {
482        let g = refined.pbsamp_to_group[0][pbsamp];
483        for &c in cells {
484            cell_to_group_finest[c] = g;
485        }
486    }
487    let finest_str = pad_numeric_labels(&cell_to_group_finest, k_finest);
488    let nthreads = rayon::current_num_threads();
489    info!(
490        "Assigning {} cells to {} finest pb-sample groups ({} rayon threads) ...",
491        ncols, k_finest, nthreads
492    );
493    data_vec.assign_groups(&finest_str, None);
494    debug_assert_eq!(data_vec.num_groups(), k_finest);
495
496    let mut fine_stat = CollapsedStat::new(num_features, k_finest, num_batches);
497    fine_stat.exclude_unmatched_from_delta = ctx.exclude_unmatched_from_delta;
498    info!(
499        "Collecting observed stats over {} groups × {} batches from {} pb-samples ...",
500        k_finest, num_batches, num_pb
501    );
502    collect_observed_stat_from_pb_samples(
503        data_vec,
504        &pb_samples.layout,
505        &pb_samples.gene_sums,
506        &refined.pbsamp_to_group[0],
507        &mut fine_stat,
508    )?;
509    if let Some(matches) = matches.for_matched_stat() {
510        info!(
511            "Collecting cross-batch matched stats (knn={}) over {} pb-samples ...",
512            knn, num_pb
513        );
514        collect_matched_stat_coarse(
515            &pb_samples.layout,
516            &pb_samples.gene_sums,
517            &refined.pbsamp_to_group[0],
518            matches,
519            ctx.anchor_batches,
520            &mut fine_stat,
521        )?;
522    }
523
524    info!(
525        "Level 1/{}: refined k={} (finest; {} cells)",
526        num_levels, k_finest, ncols
527    );
528    if ctx.observe_panels {
529        attach_observability(&mut fine_stat, data_vec)?;
530    }
531
532    let mut results: Vec<CollapsedOut> = Vec::with_capacity(num_levels);
533    let finest_out = optimize(
534        &fine_stat,
535        (1.0, 1.0),
536        opt_iter,
537        &format!("Fit L1/{}", num_levels),
538        output_calibration,
539        ctx.keep_finest_stats,
540    )?;
541    results.push(finest_out);
542
543    let mut prev_stat = fine_stat;
544    for level in 1..num_levels {
545        let k_prev = refined.num_groups_per_level[level - 1];
546        let k_level = refined.num_groups_per_level[level];
547        let fine_to_coarse = fine_to_coarse_from_refined(
548            &refined.pbsamp_to_group[level - 1],
549            &refined.pbsamp_to_group[level],
550            k_prev,
551        );
552        let coarse_stat = merge_stat(&prev_stat, &fine_to_coarse, k_level);
553        info!(
554            "Level {}/{}: refined k={} (merged from {})",
555            level + 1,
556            num_levels,
557            k_level,
558            k_prev
559        );
560        let level_opt_iter = (opt_iter / 2).max(10);
561        let out = optimize(
562            &coarse_stat,
563            (1.0, 1.0),
564            level_opt_iter,
565            &format!("Fit L{}/{}", level + 1, num_levels),
566            output_calibration,
567            false,
568        )?;
569        results.push(out);
570        prev_stat = coarse_stat;
571    }
572    info!(
573        "Fitted pseudobulk posteriors for {} refined levels: k = {:?} (finest first)",
574        num_levels, refined.num_groups_per_level
575    );
576
577    let cell_to_pb_per_level = cell_to_pb_per_level(&refined, &pb_sample_to_cells, ncols);
578
579    // Tree: attach the finest pb ids behind every leaf code.
580    let pb_tree = ctx.pb_tree.map(|tree| {
581        let mut tree = tree.clone();
582        let mut leaf_pbs: HashMap<usize, std::collections::BTreeSet<usize>> = HashMap::default();
583        if let Some(finest) = cell_to_pb_per_level.first() {
584            for (c, &code) in fine_codes.iter().enumerate() {
585                leaf_pbs.entry(code).or_default().insert(finest[c]);
586            }
587        }
588        let mut leaves: Vec<(usize, Vec<usize>)> = leaf_pbs
589            .into_iter()
590            .map(|(code, pbs)| (code, pbs.into_iter().collect()))
591            .collect();
592        leaves.sort_by_key(|x| x.0);
593        tree.leaf_to_finest_pb = leaves;
594        tree
595    });
596
597    Ok(MultilevelCollapseOut {
598        levels: results,
599        cell_to_pb_per_level,
600        pb_tree,
601    })
602}
603
604/// Per-level cell → pb mapping (finest-first), walking each level's
605/// `pbsamp_to_group` through `pb_sample_to_cells`.
606fn cell_to_pb_per_level(
607    refined: &crate::alg::refine_multilevel::RefinedAssignment,
608    pb_sample_to_cells: &[Vec<usize>],
609    ncols: usize,
610) -> Vec<Vec<usize>> {
611    refined
612        .pbsamp_to_group
613        .iter()
614        .map(|groups| {
615            let mut c2g = vec![0usize; ncols];
616            for (pbsamp, cells) in pb_sample_to_cells.iter().enumerate() {
617                for &c in cells {
618                    c2g[c] = groups[pbsamp];
619                }
620            }
621            c2g
622        })
623        .collect()
624}
625
626/// What the stack collapse returns: per level (finest first), one
627/// `CollapsedOut` per layer, and the per-level cell → pb membership the layers
628/// share.
629pub struct StackCollapseOut {
630    pub levels: Vec<Vec<CollapsedOut>>,
631    pub cell_to_pb_per_level: Vec<Vec<usize>>,
632}
633
634/// Refinement integration path for `SparseIoStack`.
635///
636/// Shares one `RefinedAssignment` across all layers (first-layer-owns the
637/// grouping decision, matching the existing stack convention). Per level ×
638/// layer we rebuild `CollapsedStat` and emit `CollapsedOut`.
639pub(super) fn refine_and_collect_stack(
640    stack: &mut SparseIoStack,
641    proj_kn: &DMatrix<f32>,
642    ctx: &RefineCollectCtx<'_>,
643) -> anyhow::Result<StackCollapseOut> {
644    let RefineCollectCtx {
645        fine_codes,
646        group_to_cols_finest,
647        level_dims,
648        num_features: _,
649        num_batches,
650        knn,
651        opt_iter,
652        refine_params,
653        output_calibration,
654        anchor_batches: _,
655        summary_batches: _,
656        bulk_batches: _,
657        observe_panels: _,
658        keep_finest_stats,
659        pb_tree: _,
660        cell_to_stratum,
661        exclude_unmatched_from_delta,
662        strata_bits,
663    } = *ctx;
664    let num_layers = stack.num_types();
665    info!(
666        "Multi-level stack refinement (BBKNN + DC-SBM): {} layers × {} levels",
667        num_layers,
668        level_dims.len()
669    );
670
671    let ncols = proj_kn.ncols();
672    let col_to_batch: Vec<usize> = stack.stack[0].get_batch_membership(0..ncols);
673
674    // Build shared pb-sample layout from layer[0]'s row count and the shared
675    // projection. The layout only uses `proj_kn` + grouping, no raw reads.
676    let layout = build_pb_sample_layout(
677        group_to_cols_finest,
678        &col_to_batch,
679        proj_kn,
680        None,
681        &[],
682        &[],
683        cell_to_stratum,
684    )?;
685    let num_pb = layout.cell_counts.len();
686
687    // Gene sums for layer[0] drive the refinement (first-layer-owns).
688    let owner_num_features = stack.stack[0].num_rows();
689    let gene_sums_owner = collect_pb_sample_gene_sums(
690        &stack.stack[0],
691        group_to_cols_finest,
692        &layout.cell_to_pbsamp,
693        num_pb,
694    )?;
695
696    let pb_sample_to_cells = build_pb_sample_to_cells(&layout);
697
698    let initial_per_level =
699        initial_per_level_from_hash(fine_codes, &pb_sample_to_cells, level_dims, strata_bits);
700    let empty: [ColumnDict<usize>; 0] = [];
701    let batch_knn: &[ColumnDict<usize>] = if num_batches >= 2 {
702        stack.stack[0]
703            .batch_knn_lookup()
704            .ok_or_else(|| anyhow::anyhow!("batch_knn_lookup not built"))?
705            .as_slice()
706    } else {
707        &empty
708    };
709    let reproject_offsets =
710        build_reproject_offsets(fine_codes, &pb_sample_to_cells, level_dims, strata_bits);
711    let inputs = crate::alg::refine_multilevel::RefineInputs {
712        layout: &layout,
713        gene_sums: &gene_sums_owner,
714        num_genes: owner_num_features,
715        pb_sample_to_cells: &pb_sample_to_cells,
716        batch_knn_lookup: batch_knn,
717        k_per_batch: knn,
718        initial_sc_to_group_per_level: &initial_per_level,
719        reproject_offsets_per_level: &reproject_offsets,
720    };
721    // One set of tables for every layer: the layers share the layout and
722    // index the same projection, so their lookups return the same matches.
723    let matches = cross_batch_matches(&layout, batch_knn, knn, ctx.anchor_batches, refine_params)?;
724    let refined = refine_or_identity(
725        num_batches >= 2,
726        &inputs,
727        refine_params,
728        matches.for_refinement(),
729    )?;
730
731    // Per-layer gene_sums for the remaining layers (layer 0 reuses `gene_sums_owner`).
732    let mut per_layer_gene_sums: Vec<GeneSums> = Vec::with_capacity(num_layers);
733    for (d, layer) in stack.stack.iter().enumerate() {
734        if d == 0 {
735            per_layer_gene_sums.push(gene_sums_owner.clone());
736        } else {
737            per_layer_gene_sums.push(collect_pb_sample_gene_sums(
738                layer,
739                group_to_cols_finest,
740                &layout.cell_to_pbsamp,
741                num_pb,
742            )?);
743        }
744    }
745
746    // Finest CollapsedStat per layer from its pb-sample aggregates, then
747    //    descend into coarser levels by `merge_stat` on column aggregates.
748    let num_levels = level_dims.len();
749    let k_finest = refined.num_groups_per_level[0];
750    let mut cell_to_group_finest = vec![0usize; ncols];
751    for (pbsamp, cells) in pb_sample_to_cells.iter().enumerate() {
752        let g = refined.pbsamp_to_group[0][pbsamp];
753        for &c in cells {
754            cell_to_group_finest[c] = g;
755        }
756    }
757    let finest_str = pad_numeric_labels(&cell_to_group_finest, k_finest);
758    let nthreads = rayon::current_num_threads();
759    info!(
760        "Assigning {} cells to {} finest pb-sample groups across {} layers ({} rayon threads) ...",
761        ncols, k_finest, num_layers, nthreads
762    );
763    for layer in stack.stack.iter_mut() {
764        layer.assign_groups(&finest_str, None);
765    }
766
767    let mut fine_stats: Vec<CollapsedStat> = Vec::with_capacity(num_layers);
768    let mut finest_layer_results = Vec::with_capacity(num_layers);
769    for (d, layer) in stack.stack.iter().enumerate() {
770        let num_features = layer.num_rows();
771        let mut stat = CollapsedStat::new(num_features, k_finest, num_batches);
772        stat.exclude_unmatched_from_delta = exclude_unmatched_from_delta;
773        info!(
774            "Layer {}/{}: collecting observed stats over {} groups × {} batches ...",
775            d + 1,
776            num_layers,
777            k_finest,
778            num_batches
779        );
780        collect_observed_stat_from_pb_samples(
781            layer,
782            &layout,
783            &per_layer_gene_sums[d],
784            &refined.pbsamp_to_group[0],
785            &mut stat,
786        )?;
787        if let Some(matches) = matches.for_matched_stat() {
788            info!(
789                "Layer {}/{}: collecting cross-batch matched stats (knn={}) over {} pb-samples ...",
790                d + 1,
791                num_layers,
792                knn,
793                num_pb
794            );
795            collect_matched_stat_coarse(
796                &layout,
797                &per_layer_gene_sums[d],
798                &refined.pbsamp_to_group[0],
799                matches,
800                ctx.anchor_batches,
801                &mut stat,
802            )?;
803        }
804        let out = optimize(
805            &stat,
806            (1.0, 1.0),
807            opt_iter,
808            &format!("Fit L1/{} layer {}/{}", num_levels, d + 1, num_layers),
809            output_calibration,
810            keep_finest_stats,
811        )?;
812        finest_layer_results.push(out);
813        fine_stats.push(stat);
814    }
815    info!(
816        "Level 1/{}: refined k={} (finest; {} layers × {} cells)",
817        num_levels, k_finest, num_layers, ncols
818    );
819    let mut results: Vec<Vec<CollapsedOut>> = Vec::with_capacity(num_levels);
820    results.push(finest_layer_results);
821
822    let mut prev_stats = fine_stats;
823    for level in 1..num_levels {
824        let k_prev = refined.num_groups_per_level[level - 1];
825        let k_level = refined.num_groups_per_level[level];
826        let fine_to_coarse = fine_to_coarse_from_refined(
827            &refined.pbsamp_to_group[level - 1],
828            &refined.pbsamp_to_group[level],
829            k_prev,
830        );
831        let level_opt_iter = (opt_iter / 2).max(10);
832        let mut layer_results = Vec::with_capacity(num_layers);
833        let mut coarse_stats = Vec::with_capacity(num_layers);
834        for (d, prev_stat) in prev_stats.iter().enumerate() {
835            let coarse_stat = merge_stat(prev_stat, &fine_to_coarse, k_level);
836            let out = optimize(
837                &coarse_stat,
838                (1.0, 1.0),
839                level_opt_iter,
840                &format!(
841                    "Fit L{}/{} layer {}/{}",
842                    level + 1,
843                    num_levels,
844                    d + 1,
845                    num_layers
846                ),
847                output_calibration,
848                false,
849            )?;
850            layer_results.push(out);
851            coarse_stats.push(coarse_stat);
852        }
853        info!(
854            "Level {}/{}: refined k={} (merged from {}, {} layers)",
855            level + 1,
856            num_levels,
857            k_level,
858            k_prev,
859            num_layers
860        );
861        results.push(layer_results);
862        prev_stats = coarse_stats;
863    }
864
865    Ok(StackCollapseOut {
866        levels: results,
867        cell_to_pb_per_level: cell_to_pb_per_level(&refined, &pb_sample_to_cells, ncols),
868    })
869}
870
871/// Compute sort dimensions for each level, linearly spaced from
872/// finest to coarsest (fine→coarse). Duplicate dimensions are
873/// removed so that extra levels don't repeat the same partitioning.
874pub(super) fn compute_level_sort_dims(finest_sort_dim: usize, num_levels: usize) -> Vec<usize> {
875    if num_levels <= 1 {
876        return vec![finest_sort_dim];
877    }
878    let coarsest = DEFAULT_COARSEST_SORT_DIM.min(finest_sort_dim);
879    let mut dims = Vec::with_capacity(num_levels);
880    for level in 0..num_levels {
881        // t goes from 0 (finest) to 1 (coarsest)
882        let t = level as f32 / (num_levels - 1) as f32;
883        let dim = finest_sort_dim as f32 - t * (finest_sort_dim - coarsest) as f32;
884        let dim = dim.round() as usize;
885        if dims.last() != Some(&dim) {
886            dims.push(dim);
887        }
888    }
889    dims
890}
891
892#[cfg(test)]
893#[path = "refine_tests.rs"]
894mod reproject_tests;