Skip to main content

data_beans/alg/collapse_data/
pb_samples.rs

1//! Pseudobulk-sample (pb-sample) construction.
2//!
3//! `build_pb_samples` aggregates per-(batch, group) intersections of the
4//! finest hash partition into a layout + per-pb-sample gene sums used
5//! downstream by the refinement step (`refine_multilevel` BBKNN +
6//! DC-SBM) and by the cross-batch matched-stat path.
7
8use super::*;
9
10pub struct PbSampleLayout {
11    /// Centroid matrix: proj_dim x num_pb_samples
12    pub centroids: DMatrix<f32>,
13    /// Number of cells in each pb-sample
14    pub cell_counts: Vec<f32>,
15    /// Batch index for each pb-sample
16    pub pb_sample_to_batch: Vec<usize>,
17    /// Sample/group index for each pb-sample
18    pub pb_sample_to_group: Vec<usize>,
19    /// Maps (batch, group) → pb-sample index
20    pub bg_to_pbsamp: HashMap<(usize, usize), usize>,
21    /// Global cell index → owning pb-sample index.
22    pub cell_to_pbsamp: Vec<usize>,
23    /// For a singleton pb-sample (an anchored reference column or a bulk
24    /// sample), the one column it stands for; `None` for ordinary
25    /// (batch, group) pb-samples. The layout is the single owner of "which
26    /// pb-samples are already summaries" — downstream steps
27    /// (`split_anchored_finest_groups`) read it here instead of re-deriving
28    /// membership from batch labels.
29    pub singleton_col: Vec<Option<usize>>,
30    /// Batches whose columns are bulk samples: singletons like anchors, but
31    /// excluded from cross-batch matching in BOTH directions (see
32    /// [`bbknn_match_one_pbsamp`]).
33    pub bulk_batches: Vec<usize>,
34    /// Per-pb-sample CNV stratum when collapse is stratified. Same-stratum
35    /// BBKNN filter reads this; `None` disables the filter.
36    pub pb_sample_to_stratum: Option<Vec<usize>>,
37}
38
39impl PbSampleLayout {
40    /// Whether this pb-sample is a bulk sample (a singleton that takes no
41    /// part in matching).
42    #[must_use]
43    pub fn is_bulk(&self, pbsamp: usize) -> bool {
44        self.is_bulk_batch(self.pb_sample_to_batch[pbsamp])
45    }
46
47    /// Whether `batch` is a bulk batch — the one spelling of the predicate,
48    /// so per-pb-sample and per-batch callers cannot drift apart.
49    #[must_use]
50    pub fn is_bulk_batch(&self, batch: usize) -> bool {
51        self.bulk_batches.contains(&batch)
52    }
53}
54
55/// Pre-aggregated pb-sample data for fast cross-batch matching.
56/// Each pb-sample is the intersection of a (batch, sample) pair.
57pub struct PbSampleCollection {
58    pub layout: PbSampleLayout,
59    /// Sparse gene sums per pb-sample: Vec of (gene_index, sum)
60    pub gene_sums: Vec<Vec<(usize, f32)>>,
61    /// Number of genes
62    pub num_genes: usize,
63}
64
65/// Intermediate per-batch accumulator used during pb-sample construction.
66pub(super) struct BatchAccumulator {
67    centroid_sum: Vec<f32>,
68    gene_sum: HashMap<usize, f32>,
69    count: usize,
70}
71
72/// A single pb-sample produced from a (batch, group) intersection.
73pub(super) struct PbSampleData {
74    centroid: Vec<f32>,
75    gene_sums: Vec<(usize, f32)>,
76    cell_count: f32,
77    batch: usize,
78    group: usize,
79}
80
81/// Build the shared pb-sample layout from (batch, group) intersections.
82///
83/// For each non-empty (batch, group) block:
84/// - Centroid = mean of projection vectors
85/// - Cell count = number of cells in block
86///
87/// `anchor_batches` are batches whose columns are ALREADY pseudobulks — a
88/// prior run's carried reference. Re-averaging those through this run's
89/// partition would flatten resolution the parent already paid for (averages
90/// of averages), so each anchored column becomes its own singleton pb-sample
91/// instead, keeping the reference at its stored granularity for matching.
92/// `bulk_batches` get the same singleton treatment (a bulk sample is already
93/// a summary) but are additionally barred from matching — see
94/// [`bbknn_match_one_pbsamp`].
95///
96/// This only uses `proj_kn` (no CSC reads), so it can be shared across layers.
97pub(super) fn build_pb_sample_layout(
98    group_to_cols: &[Vec<usize>],
99    col_to_batch: &[usize],
100    proj_kn: &DMatrix<f32>,
101    col_weight: Option<&[f32]>,
102    anchor_batches: &[usize],
103    bulk_batches: &[usize],
104    cell_to_stratum: Option<&[usize]>,
105) -> anyhow::Result<PbSampleLayout> {
106    let proj_dim = proj_kn.nrows();
107
108    /// Intermediate per-batch accumulator for centroid computation.
109    struct CentroidAccum {
110        centroid_sum: Vec<f32>,
111        /// Summed multiplicity, not a raw column count: a column standing for
112        /// `m` cells must weigh `m` in both the centroid and `cell_counts`,
113        /// or a carried pseudobulk counts the same as one cell.
114        count: f32,
115    }
116    let weight_of = |c: usize| col_weight.map_or(1.0, |w| w[c]);
117
118    // Collect centroid data per group in parallel. The last tuple field is
119    // `Some(column)` for a singleton (anchored) pb-sample, `None` for an
120    // ordinary (batch, group) block.
121    type CentroidTuple = (usize, usize, Vec<f32>, f32, Option<usize>);
122    let per_group_results: Vec<Vec<CentroidTuple>> = group_to_cols
123        .par_iter()
124        .enumerate()
125        .map(|(group, cells)| {
126            let mut batch_data: HashMap<usize, CentroidAccum> = HashMap::default();
127            let mut singletons: Vec<CentroidTuple> = Vec::new();
128
129            for &glob_idx in cells {
130                let batch = col_to_batch[glob_idx];
131                let w = weight_of(glob_idx);
132                if anchor_batches.contains(&batch) || bulk_batches.contains(&batch) {
133                    let centroid: Vec<f32> =
134                        (0..proj_dim).map(|d| proj_kn[(d, glob_idx)]).collect();
135                    singletons.push((batch, group, centroid, w, Some(glob_idx)));
136                    continue;
137                }
138                let acc = batch_data.entry(batch).or_insert_with(|| CentroidAccum {
139                    centroid_sum: vec![0f32; proj_dim],
140                    count: 0.0,
141                });
142                for d in 0..proj_dim {
143                    acc.centroid_sum[d] += proj_kn[(d, glob_idx)] * w;
144                }
145                acc.count += w;
146            }
147
148            let mut out: Vec<CentroidTuple> = batch_data
149                .into_iter()
150                .filter(|(_, acc)| acc.count > 0.0)
151                .map(|(batch, acc)| {
152                    let inv_count = 1.0 / acc.count;
153                    let centroid: Vec<f32> =
154                        acc.centroid_sum.iter().map(|v| v * inv_count).collect();
155                    (batch, group, centroid, acc.count, None)
156                })
157                .collect::<Vec<_>>();
158            out.extend(singletons);
159            out
160        })
161        .collect();
162
163    // Flatten into a single list
164    let all_pbsamp: Vec<_> = per_group_results.into_iter().flatten().collect();
165    let num_pb = all_pbsamp.len();
166
167    if num_pb == 0 {
168        return Err(anyhow::anyhow!("no pb-samples built"));
169    }
170
171    // Build centroid matrix and metadata
172    let mut centroids = DMatrix::<f32>::zeros(proj_dim, num_pb);
173    let mut cell_counts = Vec::with_capacity(num_pb);
174    let mut pbsamp_to_batch = Vec::with_capacity(num_pb);
175    let mut pbsamp_to_group = Vec::with_capacity(num_pb);
176    let mut singleton_col = Vec::with_capacity(num_pb);
177    let mut bg_to_pbsamp = HashMap::default();
178
179    let ncols = col_to_batch.len();
180    let mut cell_to_pbsamp = vec![usize::MAX; ncols];
181    for (i, (batch, group, centroid, count, sc)) in all_pbsamp.into_iter().enumerate() {
182        for (d, &v) in centroid.iter().enumerate() {
183            centroids[(d, i)] = v;
184        }
185        cell_counts.push(count);
186        pbsamp_to_batch.push(batch);
187        pbsamp_to_group.push(group);
188        singleton_col.push(sc);
189        match sc {
190            // A singleton column IS its pb-sample; `(batch, group)` is not a
191            // unique key for singletons, so they are mapped directly and stay
192            // out of `bg_to_pbsamp`.
193            Some(col) => cell_to_pbsamp[col] = i,
194            None => {
195                bg_to_pbsamp.insert((batch, group), i);
196            }
197        }
198    }
199
200    // Cell → pb-sample inversion for the ordinary (batch, group) blocks;
201    // singleton columns were mapped directly above.
202    for (group, cells) in group_to_cols.iter().enumerate() {
203        for &c in cells {
204            let b = col_to_batch[c];
205            if cell_to_pbsamp[c] == usize::MAX {
206                if let Some(&pbsamp) = bg_to_pbsamp.get(&(b, group)) {
207                    cell_to_pbsamp[c] = pbsamp;
208                }
209            }
210        }
211    }
212
213    let pb_sample_to_stratum = if let Some(strata) = cell_to_stratum {
214        anyhow::ensure!(
215            strata.len() == ncols,
216            "cell_to_stratum has {} entries, layout has {} columns",
217            strata.len(),
218            ncols
219        );
220        let mut out = vec![0usize; num_pb];
221        let mut seen = vec![false; num_pb];
222        for (c, &pbsamp) in cell_to_pbsamp.iter().enumerate() {
223            if pbsamp == usize::MAX {
224                continue;
225            }
226            if !seen[pbsamp] {
227                out[pbsamp] = strata[c];
228                seen[pbsamp] = true;
229            }
230        }
231        Some(out)
232    } else {
233        None
234    };
235
236    Ok(PbSampleLayout {
237        centroids,
238        cell_counts,
239        pb_sample_to_batch: pbsamp_to_batch,
240        pb_sample_to_group: pbsamp_to_group,
241        bg_to_pbsamp,
242        cell_to_pbsamp,
243        singleton_col,
244        bulk_batches: bulk_batches.to_vec(),
245        pb_sample_to_stratum,
246    })
247}
248
249/// Collect gene sums for each pb-sample from a single `SparseIoVec` layer.
250///
251/// Keyed on the layout's `cell_to_pbsamp` so ordinary (batch, group) blocks
252/// and singleton (anchored) pb-samples accumulate through one path.
253pub(super) fn collect_pb_sample_gene_sums(
254    data_vec: &SparseIoVec,
255    group_to_cols: &[Vec<usize>],
256    cell_to_pbsamp: &[usize],
257    num_pb: usize,
258) -> anyhow::Result<Vec<Vec<(usize, f32)>>> {
259    use indicatif::ParallelProgressIterator;
260    let num_genes = data_vec.num_rows();
261    let prog_bar = styled_progress_bar(group_to_cols.len() as u64, "groups (pb-sample gene sums)");
262    // One dense gene accumulator per worker, reset through the list of genes
263    // a pb-sample touched, so each nonzero costs an indexed add.
264    let per_group: Vec<Vec<_>> = group_to_cols
265        .par_iter()
266        .progress_with(prog_bar.clone())
267        .map_init(
268            || (vec![0f32; num_genes], vec![false; num_genes], Vec::new()),
269            |(sums, seen, touched), cells| -> anyhow::Result<_> {
270                let yy = data_vec.read_columns_csc(cells.iter().cloned())?;
271
272                // A pb-sample lies within one group; the stable sort lines its
273                // cells up as one run, in group order.
274                let mut by_pbsamp: Vec<(usize, usize)> = cells
275                    .iter()
276                    .enumerate()
277                    .map(|(local_idx, &col)| (cell_to_pbsamp[col], local_idx))
278                    .filter(|&(pbsamp, _)| pbsamp != usize::MAX)
279                    .collect();
280                by_pbsamp.sort_by_key(|&(pbsamp, _)| pbsamp);
281
282                let mut out: Vec<(usize, Vec<(usize, f32)>)> = Vec::new();
283                for run in by_pbsamp.chunk_by(|a, b| a.0 == b.0) {
284                    for &(_, local_idx) in run {
285                        // Weighted to stay consistent with the weighted
286                        // `cell_counts` in the layout:
287                        // `collect_matched_stat_coarse` divides one by the
288                        // other, and the quotient has to remain the per-cell
289                        // rate.
290                        let w = data_vec.column_multiplicity(cells[local_idx]);
291                        let y_j = yy.col(local_idx);
292                        for (&gene, &val) in y_j.row_indices().iter().zip(y_j.values().iter()) {
293                            if !seen[gene] {
294                                seen[gene] = true;
295                                touched.push(gene);
296                            }
297                            sums[gene] += val * w;
298                        }
299                    }
300                    touched.sort_unstable();
301                    let sorted: Vec<(usize, f32)> = touched
302                        .drain(..)
303                        .map(|gene| {
304                            seen[gene] = false;
305                            (gene, std::mem::take(&mut sums[gene]))
306                        })
307                        .collect();
308                    out.push((run[0].0, sorted));
309                }
310                Ok(out)
311            },
312        )
313        .collect::<anyhow::Result<_>>()?;
314    prog_bar.finish_and_clear();
315
316    let mut gene_sums: Vec<Vec<(usize, f32)>> = vec![vec![]; num_pb];
317    for (pbsamp_idx, sorted) in per_group.into_iter().flatten() {
318        gene_sums[pbsamp_idx] = sorted;
319    }
320
321    Ok(gene_sums)
322}
323
324/// Build pb-samples (layout + gene sums) from a single `SparseIoVec`.
325pub(super) fn build_pb_samples(
326    data_vec: &SparseIoVec,
327    proj_kn: &DMatrix<f32>,
328    num_genes: usize,
329    anchor_batches: &[usize],
330    bulk_batches: &[usize],
331    cell_to_stratum: Option<&[usize]>,
332) -> anyhow::Result<PbSampleCollection> {
333    let group_to_cols = data_vec
334        .take_grouped_columns()
335        .ok_or(anyhow::anyhow!("columns not assigned to groups"))?;
336    let col_to_batch: Vec<usize> = (0..proj_kn.ncols())
337        .map(|c| data_vec.get_batch_membership(std::iter::once(c))[0])
338        .collect();
339
340    let weights: Option<Vec<f32>> = data_vec.has_column_multiplicity().then(|| {
341        (0..proj_kn.ncols())
342            .map(|c| data_vec.column_multiplicity(c))
343            .collect()
344    });
345    let layout = build_pb_sample_layout(
346        group_to_cols,
347        &col_to_batch,
348        proj_kn,
349        weights.as_deref(),
350        anchor_batches,
351        bulk_batches,
352        cell_to_stratum,
353    )?;
354    let num_pb = layout.cell_counts.len();
355    let gene_sums =
356        collect_pb_sample_gene_sums(data_vec, group_to_cols, &layout.cell_to_pbsamp, num_pb)?;
357
358    Ok(PbSampleCollection {
359        layout,
360        gene_sums,
361        num_genes,
362    })
363}
364
365/// How many distinct pb-samples each batch's cell index can return, in total
366/// and (when the layout is stratified) per stratum. A match search for `knn`
367/// pb-samples is complete once it holds `min(knn, count)` of them, so it need
368/// not exhaust a batch that has fewer than `knn` to offer.
369pub(crate) struct MatchablePbSamples {
370    per_batch: Vec<usize>,
371    per_batch_stratum: Option<Vec<HashMap<usize, usize>>>,
372}
373
374impl MatchablePbSamples {
375    pub(crate) fn new(layout: &PbSampleLayout, batch_knn_lookup: &[ColumnDict<usize>]) -> Self {
376        let strata = layout.pb_sample_to_stratum.as_deref();
377        let (per_batch, by_stratum): (Vec<usize>, Vec<HashMap<usize, usize>>) = batch_knn_lookup
378            .par_iter()
379            .map(|bknn| {
380                let mut pbsamps: Vec<usize> = bknn
381                    .names()
382                    .iter()
383                    .map(|&c| layout.cell_to_pbsamp[c])
384                    .filter(|&p| p != usize::MAX)
385                    .collect();
386                pbsamps.sort_unstable();
387                pbsamps.dedup();
388                let mut by_stratum: HashMap<usize, usize> = HashMap::default();
389                if let Some(st) = strata {
390                    for &p in &pbsamps {
391                        *by_stratum.entry(st[p]).or_default() += 1;
392                    }
393                }
394                (pbsamps.len(), by_stratum)
395            })
396            .unzip();
397        Self {
398            per_batch,
399            per_batch_stratum: strata.map(|_| by_stratum),
400        }
401    }
402
403    /// Distinct pb-samples batch `batch` holds for a query of `stratum`
404    /// (`None` = unstratified: all of them).
405    fn count(&self, batch: usize, stratum: Option<usize>) -> usize {
406        match (self.per_batch_stratum.as_ref(), stratum) {
407            (Some(by_stratum), Some(s)) => by_stratum[batch].get(&s).copied().unwrap_or(0),
408            _ => self.per_batch[batch],
409        }
410    }
411}
412
413/// Recover up to `knn` *distinct* pb-samples (closest, by min cell distance)
414/// from a single batch's cell-level HNSW for the given `query` centroid.
415///
416/// Grows the HNSW query adaptively until `knn` distinct pb-samples are found or
417/// the batch's cells are exhausted (bounded by `bknn.len()`). This replaces the
418/// old fixed `4·knn+1` oversample, which under-recovered whenever a batch's
419/// nearest cells collapsed into fewer than `knn` pb-samples (small / sorting-
420/// biased batches) — starving the cross-batch δ estimate of matches.
421///
422/// `available` is the number of distinct pb-samples in `bknn` the stratum
423/// filter lets through ([`MatchablePbSamples`]): the search is complete once
424/// it holds `min(knn, available)` of them, and has nothing to do when there
425/// are none. A count above the true number only defers the stop to exhaustion.
426///
427/// `own_pbsamp` and `usize::MAX` (unassigned) cells are skipped. `stratum` is
428/// the per-pb-sample stratum table with the query's own stratum; only
429/// pb-samples of that stratum are kept. Returned distance per pb-sample is the
430/// minimum over its collapsed cells.
431pub(crate) fn knn_distinct_pbsamples_in_batch(
432    bknn: &ColumnDict<usize>,
433    query: &[f32],
434    knn: usize,
435    available: usize,
436    cell_to_pbsamp: &[usize],
437    own_pbsamp: usize,
438    stratum: Option<(&[usize], usize)>,
439) -> anyhow::Result<Vec<(usize, f32)>> {
440    let n = bknn.num_points();
441    let wanted = knn.min(available);
442    if n == 0 || wanted == 0 {
443        return Ok(Vec::new());
444    }
445    // Start at the historical oversample, then grow ×4 per round. `search_by_
446    // query_data` self-clamps the request to the batch size, so query_k == n
447    // returns every cell — our exhaustion signal.
448    let mut query_k = (knn * 4 + 1).min(n);
449    let mut best: HashMap<usize, f32> = HashMap::default();
450    loop {
451        let (cell_ids, dists) = bknn.search_by_query_data(query, query_k)?;
452        best.clear();
453        for (&c, &d) in cell_ids.iter().zip(dists.iter()) {
454            let other_pbsamp = cell_to_pbsamp[c];
455            if other_pbsamp == usize::MAX || other_pbsamp == own_pbsamp {
456                continue;
457            }
458            if stratum.is_some_and(|(st, own_s)| st[other_pbsamp] != own_s) {
459                continue;
460            }
461            best.entry(other_pbsamp)
462                .and_modify(|old| {
463                    if d < *old {
464                        *old = d;
465                    }
466                })
467                .or_insert(d);
468        }
469        if best.len() >= wanted || query_k >= n {
470            break;
471        }
472        query_k = query_k.saturating_mul(4).min(n);
473    }
474    let mut per_batch: Vec<(usize, f32)> = best.drain().collect();
475    per_batch.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
476    per_batch.truncate(knn);
477    Ok(per_batch)
478}
479
480/// For a single `pbsamp`, gather its cross-batch match set: up to `knn`
481/// distinct pb-samples from **each** non-own batch (closest to `pbsamp`'s
482/// centroid), flattened across batches via [`knn_distinct_pbsamples_in_batch`].
483///
484/// The per-pb-sample body of [`per_batch_sc_neighbors`]; `matchable` is the
485/// per-batch census of `batch_knn_lookup` that bounds each search.
486fn bbknn_match_one_pbsamp(
487    layout: &PbSampleLayout,
488    batch_knn_lookup: &[ColumnDict<usize>],
489    matchable: &MatchablePbSamples,
490    knn: usize,
491    pbsamp: usize,
492    anchor_batches: Option<&[usize]>,
493) -> anyhow::Result<Vec<(usize, f32)>> {
494    // Bulk is a RECEIVER but never a SOURCE — greedy correction, the same
495    // discipline the carried pb_reference uses. A bulk column draws its
496    // counterfactual from the cell frame and is corrected toward it; the
497    // cells self-match through the anchor path, so their frame never moves.
498    // Letting bulk serve as a source is what would drag the cells, and that
499    // is the only direction excluded here (below, and by keeping bulk out of
500    // every anchor set).
501    let pbsamp_batch = layout.pb_sample_to_batch[pbsamp];
502    let centroid: Vec<f32> = layout.centroids.column(pbsamp).iter().copied().collect();
503    let stratum = layout
504        .pb_sample_to_stratum
505        .as_deref()
506        .map(|st| (st, st[pbsamp]));
507    let own_stratum = stratum.map(|(_, own_s)| own_s);
508    let mut all_hits: Vec<(usize, f32)> = Vec::new();
509    match anchor_batches {
510        // Pooled matching: every non-own, non-bulk batch contributes
511        // counterfactual candidates, symmetrically. A bulk batch cannot be a
512        // source: matching a cell pb-sample to a mixture is the same
513        // composition-into-δ leak in the other direction.
514        None => {
515            for (b, bknn) in batch_knn_lookup.iter().enumerate() {
516                if b == pbsamp_batch || layout.is_bulk_batch(b) {
517                    continue;
518                }
519                let per_batch = knn_distinct_pbsamples_in_batch(
520                    bknn,
521                    &centroid,
522                    knn,
523                    matchable.count(b, own_stratum),
524                    &layout.cell_to_pbsamp,
525                    pbsamp,
526                    stratum,
527                )?;
528                all_hits.extend(per_batch);
529            }
530        }
531        // Greedy (anchored) matching: counterfactuals come from the anchor
532        // batches ONLY, for everyone. New batches are corrected toward the
533        // anchor frame; an anchor pb-sample's nearest anchor is itself
534        // (distance 0 → softmax self-match), so the frame stays fixed and its
535        // δ settles at the prior — the reference is never re-adjusted.
536        Some(anchors) => {
537            for &b in anchors {
538                let Some(bknn) = batch_knn_lookup.get(b) else {
539                    continue;
540                };
541                let per_batch = knn_distinct_pbsamples_in_batch(
542                    bknn,
543                    &centroid,
544                    knn,
545                    matchable.count(b, own_stratum),
546                    &layout.cell_to_pbsamp,
547                    usize::MAX, // self-match allowed
548                    stratum,
549                )?;
550                all_hits.extend(per_batch);
551            }
552        }
553    }
554    Ok(all_hits)
555}
556
557/// Per-pb-sample, for each non-own batch return up to `knn` distinct
558/// pb-samples whose member cells are closest to `pbsamp`'s centroid.
559///
560/// Queries `SparseIoVec::batch_knn_lookup` (per-batch HNSW over cells) via
561/// [`knn_distinct_pbsamples_in_batch`], which grows each per-batch search until
562/// `knn` distinct pb-samples are recovered or the batch is exhausted.
563///
564/// With `anchor_batches`, the sources are the anchor batches only, for every
565/// pb-sample (see [`bbknn_match_one_pbsamp`]).
566///
567/// Returns: `result[pbsamp] = Vec<(other_pbsamp, distance)>` flattened across all
568/// non-own batches.
569pub(crate) fn per_batch_sc_neighbors(
570    layout: &PbSampleLayout,
571    batch_knn_lookup: &[ColumnDict<usize>],
572    knn: usize,
573    anchor_batches: Option<&[usize]>,
574) -> anyhow::Result<Vec<Vec<(usize, f32)>>> {
575    use indicatif::ParallelProgressIterator;
576    let num_pb = layout.cell_counts.len();
577    let matchable = MatchablePbSamples::new(layout, batch_knn_lookup);
578
579    let prog_bar = styled_progress_bar(num_pb as u64, "pb-samples (BBKNN match)");
580    let result = (0..num_pb)
581        .into_par_iter()
582        .progress_with(prog_bar.clone())
583        .map(|pbsamp| {
584            bbknn_match_one_pbsamp(
585                layout,
586                batch_knn_lookup,
587                &matchable,
588                knn,
589                pbsamp,
590                anchor_batches,
591            )
592        })
593        .collect();
594    prog_bar.finish_and_clear();
595    result
596}
597
598/// Match pb-samples across batches and accumulate counterfactual
599/// statistics into `stat.imputed_sum_ds` and `stat.matched_bs`.
600///
601/// `pbsamp_to_group` is the per-pb-sample group assignment to use when writing
602/// into stat columns; callers pass `&layout.pb_sample_to_group` for the
603/// hash-partition mapping, or a refined mapping from
604/// `refine_multilevel::refine_assignments`.
605///
606/// `knn` is now the per-other-batch neighbour count: each pb-sample draws
607/// up to `knn` distinct foreign pb-samples from **each** non-own batch, so
608/// the total match set is up to `knn · (num_batches − 1)`.
609pub(super) fn build_pb_sample_to_cells(layout: &PbSampleLayout) -> Vec<Vec<usize>> {
610    let num_pb = layout.cell_counts.len();
611    let mut out: Vec<Vec<usize>> = vec![vec![]; num_pb];
612    for (c, &pbsamp) in layout.cell_to_pbsamp.iter().enumerate() {
613        if pbsamp != usize::MAX {
614            out[pbsamp].push(c);
615        }
616    }
617    out
618}
619
620#[cfg(test)]
621#[path = "pb_samples_tests.rs"]
622mod tests;