Skip to main content

data_beans/alg/
dc_poisson.rs

1//! Degree-corrected Poisson refinement core.
2//!
3//! Shared Poisson-scoring machinery for top-down multi-level refinement.
4//! Given per-entity sparse profiles (gene sums or a dense projection) and
5//! current group labels, refinement reassigns entities between groups by
6//! scoring moves under a Poisson likelihood with size-factor offsets.
7//! Candidate-set construction is delegated to [`CandidateProposer`] so
8//! different front-ends (cross-batch BBKNN, spatial KNN graph, ...) can
9//! plug in their own neighborhood definition.
10//!
11//! The name "DC-Poisson" is deliberate: we are *not* inferring a stochastic
12//! block model — the blocks are given externally (by a prior coarsening
13//! step) and we only score membership moves. Compare to the full DC-SBM
14//! where blocks themselves are latent variables.
15//!
16//! Entry points:
17//! - [`refine_with_candidates`] — lowest-level sweep driver given pre-built candidates.
18//! - [`refine_with_proposer`] — generic driver that first asks a [`CandidateProposer`].
19//!
20//! See the `BbknnProposer` in `refine_multilevel` for the data_beans::alg
21//! front-end, and pinto's `GraphProposer` for the spatial-graph front-end.
22
23use log::info;
24use nalgebra::DMatrix;
25use rand::rngs::SmallRng;
26use rand::seq::SliceRandom;
27use rand::{RngExt, SeedableRng};
28use rayon::prelude::*;
29use rustc_hash::FxHashMap as HashMap;
30
31/// Additive floor to keep `ln(.)` finite when a block or feature has zero
32/// mass.
33pub const LOG_EPS: f64 = 1e-9;
34
35/// `ln(x)` in `f32`: libm's single-precision log, near half the cost of
36/// the `f64` one. Its error is `f32` rounding (about 1e-7 relative), the
37/// precision the cached block logs are stored at anyway. NaN, infinite and
38/// non-positive inputs keep their usual `ln` values.
39#[inline]
40fn fast_ln(x: f64) -> f64 {
41    f64::from((x as f32).ln())
42}
43
44//////////////////////////
45// Public configuration //
46//////////////////////////
47
48/// Feature representation used to score entity → block moves.
49#[derive(Clone, Debug)]
50pub enum ProfileSource {
51    /// Sparse entity × feature counts (the existing `gene_sums` format —
52    /// the field name is historical; the axis is generic).
53    /// [`FeatureWeighting`] is applied per-feature on the sparse profile.
54    Raw,
55    /// Dense projection: `basis * indicator(entity)` per entity. `basis` is
56    /// `proj_dim × num_cells`-shaped; the per-entity profile is the sum over
57    /// its member cells' projection columns. [`FeatureWeighting`] is skipped
58    /// because the feature axis is no longer count-distributed.
59    Projected { basis: DMatrix<f32> },
60}
61
62/// Per-feature weighting applied to the sparse profile before DC-Poisson
63/// scoring. All variants multiply each nonzero entry `y_{e,f}` by a scalar
64/// `w_f` and recompute per-row size factors; only the formula for `w_f`
65/// differs.
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum FeatureWeighting {
68    /// No reweighting: `w_f = 1`. Recovers proper DC-Poisson with entity-level
69    /// degree correction (Karrer–Newman MAP under `Gamma(α, 0)` on `θ`).
70    None,
71    /// Fisher-information weight under the fitted NB trend (genes-as-features
72    /// case): `w_f = 1 / (1 + π_f · s̄ · φ(μ_f))`. Bounded in `(0, 1]`,
73    /// attenuates high-mean / high-dispersion features, recovers
74    /// `w_f = 1` in the Poisson limit (`φ → 0`).
75    FisherInfoNb,
76}
77
78/// Parameters controlling the refinement pass.
79#[derive(Clone, Debug)]
80pub struct RefineParams {
81    /// Gibbs sweeps per level (0 disables Gibbs; greedy still runs).
82    pub num_gibbs: usize,
83    /// Greedy sweeps per level (early-exits on zero moves).
84    pub num_greedy: usize,
85    /// Per-feature weighting scheme (only meaningful for `Raw` profile source).
86    pub feature_weighting: FeatureWeighting,
87    /// Seed for Gibbs RNG.
88    pub seed: u64,
89    /// Gibbs stagnation threshold (fraction of entities moving per sweep).
90    /// Breaks early when three consecutive sweeps are below this bound.
91    /// `0.0` disables early exit.
92    pub gibbs_stagnation: f64,
93    /// Feature source. Defaults to `Raw`.
94    pub profile_source: ProfileSource,
95    /// Jacobi-style parallel sweeps (default `true`).
96    ///
97    /// When `true`, every entity proposes its move against a frozen pre-sweep
98    /// snapshot of the sufficient stats (rayon `par_iter`); proposals are
99    /// then applied sequentially with the move guard. Per-entity RNG seeds
100    /// are derived from `(sweep_seed, entity_idx)`, so the result is
101    /// deterministic given `seed` and independent of thread count (per
102    /// `rand` release — `SmallRng`'s stream is not guaranteed stable across
103    /// `rand` versions).
104    ///
105    /// When `false`, runs the classic Gauss–Seidel loop: each entity scores
106    /// against the live stats (updated by every accepted move so far in the
107    /// sweep), single-threaded.
108    ///
109    /// Jacobi is biased — within a sweep, entities can't see each other's
110    /// moves — but converges fine for the multilevel DC-Poisson use case
111    /// and is roughly P× faster on large pb-sample counts.
112    pub parallel: bool,
113}
114
115impl Default for RefineParams {
116    fn default() -> Self {
117        Self {
118            num_gibbs: 20,
119            num_greedy: 10,
120            feature_weighting: FeatureWeighting::FisherInfoNb,
121            seed: 42,
122            gibbs_stagnation: 0.005,
123            profile_source: ProfileSource::Raw,
124            parallel: true,
125        }
126    }
127}
128
129////////////////////////////////////
130// Sparse row-per-entity profiles //
131////////////////////////////////////
132
133/// Sparse row-per-entity profile, owned by the refinement pass.
134///
135/// Materialized from either a sparse gene-sum format (`from_gene_sums`) or a
136/// projected centroid matrix (`from_projection`). Values are stored sorted
137/// by feature index within each row so downstream accumulators can rely on
138/// the invariant.
139pub struct Profiles {
140    pub rows: Vec<Vec<(u32, f32)>>,
141    pub size_factor: Vec<f32>,
142    pub num_entities: usize,
143    pub num_features: usize,
144}
145
146impl Profiles {
147    pub fn from_gene_sums(gene_sums: &[Vec<(usize, f32)>], num_features: usize) -> Self {
148        let num_entities = gene_sums.len();
149        let (rows, size_factor): (Vec<Vec<(u32, f32)>>, Vec<f32>) = gene_sums
150            .par_iter()
151            .map(|row| {
152                let mut out: Vec<(u32, f32)> = row
153                    .iter()
154                    .filter(|(_, v)| *v > 0.0)
155                    .map(|(g, v)| (*g as u32, *v))
156                    .collect();
157                out.sort_unstable_by_key(|&(g, _)| g);
158                let sf: f32 = out.iter().map(|(_, v)| *v).sum();
159                (out, sf)
160            })
161            .unzip();
162        Self {
163            rows,
164            size_factor,
165            num_entities,
166            num_features,
167        }
168    }
169
170    /// Build profiles from a dense projection.
171    ///
172    /// `basis` is `proj_dim × num_cells`; each entity profile is the sum of
173    /// basis columns over the cells constituting it. Gene weighting is not
174    /// applied — projection dims aren't gene-aligned.
175    ///
176    /// The Poisson score needs nonnegative profiles, so the accumulated
177    /// per-dimension sums must be `>= 0` — a signed basis (e.g. a raw random
178    /// projection) is rejected loudly rather than silently truncated.
179    pub fn from_projection(basis: &DMatrix<f32>, entity_to_cells: &[Vec<usize>]) -> Self {
180        let num_features = basis.nrows();
181        let num_entities = entity_to_cells.len();
182        let (rows, size_factor): (Vec<Vec<(u32, f32)>>, Vec<f32>) = entity_to_cells
183            .par_iter()
184            .map(|cells| {
185                let mut acc = vec![0f32; num_features];
186                for &c in cells {
187                    for d in 0..num_features {
188                        acc[d] += basis[(d, c)];
189                    }
190                }
191                let mut out = Vec::with_capacity(num_features);
192                let mut sf = 0f32;
193                for (d, &v) in acc.iter().enumerate() {
194                    assert!(
195                        v >= 0.0,
196                        "projection profile is negative at dim {d} ({v}); \
197                         DC-Poisson requires a nonnegative basis"
198                    );
199                    if v > 0.0 {
200                        out.push((d as u32, v));
201                        sf += v;
202                    }
203                }
204                (out, sf)
205            })
206            .unzip();
207        Self {
208            rows,
209            size_factor,
210            num_entities,
211            num_features,
212        }
213    }
214
215    /// In-place reweighting by a caller-supplied per-feature weight vector.
216    /// Used by [`Profiles::apply_feature_weighting`] for the NB Fisher-info path.
217    pub fn weight_by_vec(&mut self, w: &[f32]) {
218        assert_eq!(
219            w.len(),
220            self.num_features,
221            "weight vector length must match num_features"
222        );
223        self.rows
224            .par_iter_mut()
225            .zip(self.size_factor.par_iter_mut())
226            .for_each(|(row, sf)| {
227                let mut new_sf = 0f32;
228                for (g, v) in row.iter_mut() {
229                    *v *= w[*g as usize];
230                    new_sf += *v;
231                }
232                *sf = new_sf;
233            });
234    }
235
236    /// Apply the chosen feature weighting in place.
237    ///
238    /// [`FeatureWeighting::None`] is a no-op; [`FeatureWeighting::FisherInfoNb`]
239    /// fits an NB dispersion trend from the current profiles and reweights each
240    /// feature by `1 / (1 + π_f · s̄ · φ(μ_f))`.
241    pub fn apply_feature_weighting(&mut self, method: FeatureWeighting) {
242        match method {
243            FeatureWeighting::None => {}
244            FeatureWeighting::FisherInfoNb => {
245                let w = self.nb_fisher_weights();
246                self.weight_by_vec(&w);
247            }
248        }
249    }
250
251    /// Compute per-feature Fisher-info weights under an NB trend fit from
252    /// the current profile contents. Returned vector has length `num_features`
253    /// and is suitable for [`Profiles::weight_by_vec`].
254    pub fn nb_fisher_weights(&self) -> Vec<f32> {
255        use crate::alg::nb_dispersion::DispersionTrend;
256        use legume_numeric::matrix::sparse_stat::SparseRunningStatistics;
257        use legume_numeric::matrix::traits::RunningStatOps;
258
259        // Rows are folded in parallel and merged in a fixed order, so the
260        // (non-integer) sums come out the same on any number of threads.
261        // Avoids the single-threaded `for row in &self.rows` over ~10⁴–10⁵
262        // pb-samples.
263        const ROWS_PER_LEAF: usize = 256;
264        let num_features = self.num_features;
265        let (stats, ..) = crate::qc::fold_in_fixed_order(
266            &self.rows,
267            ROWS_PER_LEAF,
268            &|| {
269                (
270                    SparseRunningStatistics::<f32>::new(num_features),
271                    Vec::<usize>::new(),
272                    Vec::<f32>::new(),
273                )
274            },
275            &|(acc, col_rows, col_vals), row| {
276                col_rows.clear();
277                col_vals.clear();
278                for &(g, v) in row {
279                    col_rows.push(g as usize);
280                    col_vals.push(v);
281                }
282                acc.add_sparse_column(col_rows, col_vals);
283                Ok(())
284            },
285            &|(a, ..), (b, ..)| a.merge(&b),
286        )
287        .expect("the row fold cannot fail");
288
289        // `π_g = sum[g] / Σ sum` is derived directly from the stats without
290        // a second sparse traversal. `mean_g` uses the entity-count denominator
291        // that `SparseRunningStatistics` already provides.
292        let trend = DispersionTrend::from_sparse_stats(&stats);
293        let means = stats.mean();
294        let sums = stats.sum();
295        let total_mass: f64 = sums.iter().map(|&s| s as f64).sum();
296        let avg_s = if self.num_entities > 0 {
297            (total_mass / self.num_entities as f64) as f32
298        } else {
299            1.0
300        };
301        let inv_total = if total_mass > 0.0 {
302            1.0 / total_mass as f32
303        } else {
304            0.0
305        };
306        (0..self.num_features)
307            .map(|g| trend.fisher_weight(sums[g] * inv_total, avg_s, means[g]))
308            .collect()
309    }
310}
311
312///////////////////////////////////
313// Poisson sufficient statistics //
314///////////////////////////////////
315
316/// Sufficient statistics with cached log quantities for O(K · nnz(row)) scoring.
317///
318/// `gene_sum[g·K + k]` = Σ_{e : z_e = k} y_eg; `size_sum[k]` = Σ_{e : z_e = k} s_e.
319/// The per-feature tables are laid out **feature-major** (`g·K + k`): scoring
320/// one entity walks its nonzero features once and, for each, reads the `K`
321/// group values side by side, instead of striding across a `K × M` table
322/// once per candidate group. Use [`DcPoissonStats::idx`] to address them.
323/// Log caches score every non-current block with no log; the current block's
324/// leave-one-out score takes one `fast_ln` per nonzero feature, and
325/// `delta_move` refreshes the logs of the columns a move touches.
326///
327/// Log caches are `f32` — values live in roughly `[-20, +25]` (dominated by
328/// `-ln(LOG_EPS)` at the floor), and scoring is noise-dominated so f32's
329/// ~7-digit precision is ample. Halves the largest allocation versus f64.
330#[derive(Clone)]
331pub struct DcPoissonStats {
332    pub k: usize,
333    pub num_features: usize,
334    pub membership: Vec<usize>,
335    /// Feature-major `M × K`: entry `g·K + k`.
336    pub gene_sum: Vec<f64>,
337    pub size_sum: Vec<f64>,
338    /// Feature-major `M × K`: entry `g·K + k`.
339    pub log_gene: Vec<f32>,
340    pub log_size_offset: Vec<f32>,
341}
342
343impl DcPoissonStats {
344    /// Index of (group `k`, feature `g`) in `gene_sum` / `log_gene`.
345    #[inline(always)]
346    pub fn idx(&self, k: usize, g: usize) -> usize {
347        g * self.k + k
348    }
349
350    pub fn from_profiles(profiles: &Profiles, k: usize, membership: &[usize]) -> Self {
351        let m = profiles.num_features;
352        let mut gene_sum = vec![0f64; k * m];
353        let mut size_sum = vec![0f64; k];
354        for (e, row) in profiles.rows.iter().enumerate() {
355            let z = membership[e];
356            assert!(z < k, "membership[{}]={} out of range 0..{}", e, z, k);
357            for &(g, v) in row {
358                gene_sum[g as usize * k + z] += v as f64;
359            }
360            size_sum[z] += profiles.size_factor[e] as f64;
361        }
362        let log_gene: Vec<f32> = gene_sum
363            .iter()
364            .map(|&s| fast_ln(s + LOG_EPS) as f32)
365            .collect();
366        let m_eps = m as f64 * LOG_EPS;
367        let log_size_offset: Vec<f32> = size_sum
368            .iter()
369            .map(|&s| -fast_ln(s + m_eps) as f32)
370            .collect();
371        Self {
372            k,
373            num_features: m,
374            membership: membership.to_vec(),
375            gene_sum,
376            size_sum,
377            log_gene,
378            log_size_offset,
379        }
380    }
381
382    /// Apply an entity's reassignment and refresh only the affected log entries.
383    pub fn delta_move(&mut self, e: usize, k_from: usize, k_to: usize, profiles: &Profiles) {
384        if k_from == k_to {
385            return;
386        }
387        let kk = self.k;
388        let m = self.num_features;
389        let m_eps = m as f64 * LOG_EPS;
390
391        // Subtractions clamp at zero: the running sums accumulate rounding
392        // residues, and a block drained back to (true) zero can otherwise be
393        // left with a negative residue larger than LOG_EPS — feeding
394        // `ln(negative) = NaN` into the log caches.
395        for &(g, v) in &profiles.rows[e] {
396            let base = g as usize * kk;
397            let (i_from, i_to) = (base + k_from, base + k_to);
398            self.gene_sum[i_from] = (self.gene_sum[i_from] - v as f64).max(0.0);
399            self.gene_sum[i_to] += v as f64;
400            self.log_gene[i_from] = fast_ln(self.gene_sum[i_from] + LOG_EPS) as f32;
401            self.log_gene[i_to] = fast_ln(self.gene_sum[i_to] + LOG_EPS) as f32;
402        }
403        let sf = profiles.size_factor[e] as f64;
404        self.size_sum[k_from] = (self.size_sum[k_from] - sf).max(0.0);
405        self.size_sum[k_to] += sf;
406        self.log_size_offset[k_from] = -fast_ln(self.size_sum[k_from] + m_eps) as f32;
407        self.log_size_offset[k_to] = -fast_ln(self.size_sum[k_to] + m_eps) as f32;
408
409        self.membership[e] = k_to;
410    }
411
412    /// Full recompute from current `membership` (slow path, used for tests).
413    #[cfg(test)]
414    fn recompute(&mut self, profiles: &Profiles) {
415        let k = self.k;
416        let m = self.num_features;
417        self.gene_sum.iter_mut().for_each(|x| *x = 0.0);
418        self.size_sum.iter_mut().for_each(|x| *x = 0.0);
419        for (e, row) in profiles.rows.iter().enumerate() {
420            let z = self.membership[e];
421            for &(g, v) in row {
422                self.gene_sum[g as usize * k + z] += v as f64;
423            }
424            self.size_sum[z] += profiles.size_factor[e] as f64;
425        }
426        for i in 0..k * m {
427            self.log_gene[i] = fast_ln(self.gene_sum[i] + LOG_EPS) as f32;
428        }
429        let m_eps = m as f64 * LOG_EPS;
430        for i in 0..k {
431            self.log_size_offset[i] = -fast_ln(self.size_sum[i] + m_eps) as f32;
432        }
433    }
434}
435
436/////////////////////
437// Scoring kernels //
438/////////////////////
439
440/// Score entity `e` for one destination `k` (Poisson plug-in MAP, up to a
441/// common constant):
442/// `  s(e, k) = Σ_{g : y_eg > 0} y_eg · ln(gene_sum⁻ᵉ[k, g] + ε) − size_factor[e] · ln(size_sum⁻ᵉ[k] + Mε)`
443///
444/// where `⁻ᵉ` means entity `e`'s own contribution is excluded. Candidate
445/// blocks never contain `e`, so they read straight from the log caches; the
446/// entity's *current* block subtracts `e`'s row before taking logs
447/// (leave-one-out). Scoring the current block with `e` included would give
448/// "stay put" a self-inclusion bonus that grows with the entity's size
449/// factor and shrinks with block mass — anchoring large entities in place.
450///
451/// Reference for [`compute_log_probs_restricted`], which scores all allowed
452/// blocks in one pass with the same per-block summation order (so the two
453/// agree bit for bit).
454#[cfg(test)]
455fn score_move(e: usize, k: usize, stats: &DcPoissonStats, profiles: &Profiles) -> f64 {
456    let m = stats.num_features;
457    let sf = profiles.size_factor[e] as f64;
458    let row = &profiles.rows[e];
459    if stats.membership[e] == k {
460        let m_eps = m as f64 * LOG_EPS;
461        let loo_size = (stats.size_sum[k] - sf).max(0.0);
462        let mut acc = -sf * fast_ln(loo_size + m_eps);
463        for &(g, v) in row {
464            let vg = v as f64;
465            let loo = (stats.gene_sum[stats.idx(k, g as usize)] - vg).max(0.0);
466            acc += vg * fast_ln(loo + LOG_EPS);
467        }
468        acc
469    } else {
470        let mut acc = sf * stats.log_size_offset[k] as f64;
471        for &(g, v) in row {
472            acc += v as f64 * stats.log_gene[stats.idx(k, g as usize)] as f64;
473        }
474        acc
475    }
476}
477
478/// Log-probability of placing entity `e` into each of the `allowed` blocks.
479///
480/// `  s(e, k) = Σ_{g : y_eg > 0} y_eg · ln(gene_sum⁻ᵉ[k, g] + ε) − size_factor[e] · ln(size_sum⁻ᵉ[k] + Mε)`
481///
482/// `⁻ᵉ` means entity `e`'s own contribution is excluded: candidate blocks
483/// never contain `e` and read the log caches, while `e`'s *current* block
484/// subtracts `e`'s row before taking logs (leave-one-out) — otherwise
485/// "stay put" earns a self-inclusion bonus that anchors large entities.
486///
487/// All blocks are scored in one pass over `e`'s nonzero features: per
488/// feature, the `K` cached values sit side by side (feature-major layout),
489/// so the pass touches one short contiguous run per feature. Each block's
490/// sum is still accumulated in feature order, the same order as scoring the
491/// blocks one at a time, so the scores match that bit for bit. Partial
492/// candidate lists accumulate by *position* in `allowed`, so a block listed
493/// twice gets the same, correct score in both places.
494///
495/// Only the `allowed` slots of `log_probs` are written, unless `allowed`
496/// is as long as `K`, in which case the first `K` slots are written.
497/// The rest keep whatever stale values a previous call left (the caller
498/// reuses one buffer across sweeps). Downstream consumers
499/// ([`sample_categorical_log_restricted`], [`argmax_log_restricted`]) read
500/// the `allowed` slots exclusively.
501pub fn compute_log_probs_restricted(
502    e: usize,
503    stats: &DcPoissonStats,
504    profiles: &Profiles,
505    allowed: &[usize],
506    log_probs: &mut [f64],
507) {
508    let kk = stats.k;
509    let m = stats.num_features;
510    let sf = profiles.size_factor[e] as f64;
511    let row = &profiles.rows[e];
512    let cur = stats.membership[e];
513    // Every block 0..K is scored straight into `log_probs`, so repeats in
514    // `allowed` (which then misses some block) are harmless.
515    let dense = allowed.len() == kk;
516    let score_cur = dense || allowed.contains(&cur);
517
518    // Leave-one-out score of the current block.
519    let mut cur_acc = if score_cur {
520        let loo_size = (stats.size_sum[cur] - sf).max(0.0);
521        -sf * fast_ln(loo_size + m as f64 * LOG_EPS)
522    } else {
523        0.0
524    };
525    let mut add_cur = |base: usize, vg: f64| {
526        if score_cur {
527            let loo = (stats.gene_sum[base + cur] - vg).max(0.0);
528            cur_acc += vg * fast_ln(loo + LOG_EPS);
529        }
530    };
531
532    // Cached (non-leave-one-out) scores for every allowed block, including
533    // the current one, which is overwritten at the end.
534    if dense {
535        for (slot, &o) in log_probs[..kk].iter_mut().zip(&stats.log_size_offset) {
536            *slot = sf * o as f64;
537        }
538        for &(g, v) in row {
539            let vg = v as f64;
540            let base = g as usize * kk;
541            let logs = &stats.log_gene[base..base + kk];
542            for (slot, &l) in log_probs[..kk].iter_mut().zip(logs) {
543                *slot += vg * l as f64;
544            }
545            add_cur(base, vg);
546        }
547    } else {
548        thread_local! {
549            static SCRATCH: std::cell::RefCell<Vec<f64>> = const { std::cell::RefCell::new(Vec::new()) };
550        }
551        SCRATCH.with(|scratch| {
552            let mut scratch = scratch.borrow_mut();
553            scratch.clear();
554            scratch.extend(
555                allowed
556                    .iter()
557                    .map(|&k| sf * stats.log_size_offset[k] as f64),
558            );
559            for &(g, v) in row {
560                let vg = v as f64;
561                let base = g as usize * kk;
562                let logs = &stats.log_gene[base..base + kk];
563                for (acc, &k) in scratch.iter_mut().zip(allowed) {
564                    *acc += vg * logs[k] as f64;
565                }
566                add_cur(base, vg);
567            }
568            for (&acc, &k) in scratch.iter().zip(allowed) {
569                log_probs[k] = acc;
570            }
571        });
572    }
573    if score_cur {
574        log_probs[cur] = cur_acc;
575    }
576}
577
578/// Unrestricted variant used only from tests.
579#[cfg(test)]
580fn compute_log_probs(e: usize, stats: &DcPoissonStats, profiles: &Profiles, log_probs: &mut [f64]) {
581    for (k, slot) in log_probs.iter_mut().enumerate().take(stats.k) {
582        *slot = score_move(e, k, stats, profiles);
583    }
584}
585
586/// Gumbel-max categorical sampling over the `allowed` slots of `log_probs`,
587/// skipping non-finite entries. Falls back to `current` (the entity's
588/// present label — always a legal "move") when no allowed slot is finite,
589/// so a degenerate score vector can never produce an out-of-range label.
590pub fn sample_categorical_log_restricted(
591    log_probs: &[f64],
592    allowed: &[usize],
593    current: usize,
594    rng: &mut SmallRng,
595) -> usize {
596    let mut best_key = f64::NEG_INFINITY;
597    let mut best_idx = current;
598    for &k in allowed {
599        let lp = log_probs[k];
600        if lp.is_finite() {
601            let u: f64 = rng.random_range(1e-12..1.0_f64);
602            let g = -(-u.ln()).ln();
603            let key = lp + g;
604            if key > best_key {
605                best_key = key;
606                best_idx = k;
607            }
608        }
609    }
610    best_idx
611}
612
613/// Argmax over `allowed` indices.
614pub fn argmax_log_restricted(log_probs: &[f64], allowed: &[usize]) -> usize {
615    let mut best = allowed[0];
616    let mut best_val = log_probs[best];
617    for &k in &allowed[1..] {
618        if log_probs[k] > best_val {
619            best = k;
620            best_val = log_probs[k];
621        }
622    }
623    best
624}
625
626/// Argmax over `allowed` indices that leaves `current` only for a strictly
627/// better block: a tie with the current label (an entity with no evidence
628/// scores every block alike) is not a move. With `current` outside
629/// `allowed`, ties go to the first allowed index.
630pub fn argmax_log_restricted_from(log_probs: &[f64], allowed: &[usize], current: usize) -> usize {
631    let mut best = if allowed.contains(&current) {
632        current
633    } else {
634        allowed[0]
635    };
636    let mut best_val = log_probs[best];
637    for &k in allowed {
638        if log_probs[k] > best_val {
639            best = k;
640            best_val = log_probs[k];
641        }
642    }
643    best
644}
645
646////////////////////////////////////
647// Hierarchy / relabeling helpers //
648////////////////////////////////////
649
650/// Compact a label vector to dense `0..K` ids, preserving relative order of
651/// first appearance. Returns the new labels and the new `K`. Generic over
652/// the key type so it serves both plain group ids (`usize`) and composite
653/// keys (e.g. `(child, parent)` pairs in `project_to_refinement`).
654pub fn compact_labels<K>(labels: &[K]) -> (Vec<usize>, usize)
655where
656    K: Copy + Eq + std::hash::Hash,
657{
658    let mut map: HashMap<K, usize> = HashMap::default();
659    let mut next = 0usize;
660    let mut out = Vec::with_capacity(labels.len());
661    for &g in labels {
662        let new = *map.entry(g).or_insert_with(|| {
663            let id = next;
664            next += 1;
665            id
666        });
667        out.push(new);
668    }
669    (out, next)
670}
671
672/// For each entity, siblings at `level` = groups at `level` sharing the
673/// same parent group at `level + 1`.
674///
675/// `refined` follows the finest → coarsest convention: `refined[0]` is the
676/// finest level, `refined[refined.len() - 1]` is the coarsest. At the
677/// coarsest level (index `num_levels - 1`), the virtual root groups every
678/// entity together → siblings = all groups.
679pub fn compute_sibling_sets(
680    refined: &[Vec<usize>],
681    level: usize,
682    num_groups_at_level: usize,
683) -> Vec<Vec<usize>> {
684    let num_levels = refined.len();
685    let num_entities = refined[level].len();
686
687    if level + 1 >= num_levels {
688        let all_groups: Vec<usize> = (0..num_groups_at_level).collect();
689        return vec![all_groups; num_entities];
690    }
691
692    let mut parent_to_children: HashMap<usize, Vec<usize>> = HashMap::default();
693    for (child, parent) in refined[level].iter().zip(refined[level + 1].iter()) {
694        let entry = parent_to_children.entry(*parent).or_default();
695        if !entry.contains(child) {
696            entry.push(*child);
697        }
698    }
699    for v in parent_to_children.values_mut() {
700        v.sort_unstable();
701    }
702
703    (0..num_entities)
704        .map(|e| {
705            let parent = refined[level + 1][e];
706            parent_to_children.get(&parent).cloned().unwrap_or_default()
707        })
708        .collect()
709}
710
711////////////////////////////////////////////
712// Candidate proposer + move guard traits //
713////////////////////////////////////////////
714
715/// Propose candidate group labels each entity may move into on one sweep.
716///
717/// The returned slice `candidates[e]` MUST include `labels[e]` (staying put
718/// is always legal) and MUST index into the current 0..k label space.
719pub trait CandidateProposer {
720    fn propose(&self, labels: &[usize]) -> Vec<Vec<usize>>;
721}
722
723/// Veto individual moves after they've been picked by the sweep.
724///
725/// Called once per *proposed* accepted move (`from != to`) before the move
726/// is applied to the sufficient statistics. The destination label is
727/// intentionally not passed — guards so far only need the source cluster
728/// (e.g. articulation tests ask "does removing this entity disconnect
729/// `from`?"). Add a destination-aware variant if a concrete use case
730/// surfaces.
731pub trait MoveGuard {
732    fn accept_move(&self, entity: usize, from: usize, labels: &[usize]) -> bool;
733}
734
735/// Trivial guard that accepts every move. Zero-cost default used by the
736/// unguarded [`refine_with_candidates`] and [`refine_with_proposer`].
737pub struct NoGuard;
738
739impl MoveGuard for NoGuard {
740    #[inline(always)]
741    fn accept_move(&self, _e: usize, _from: usize, _labels: &[usize]) -> bool {
742        true
743    }
744}
745
746/// Intersect a per-entity sibling set with a per-entity neighbor-group set,
747/// with sibling fallback when the intersection is empty and guaranteed
748/// inclusion of the entity's current label so staying put is always a
749/// legal move.
750///
751/// Shared between [`crate::alg::refine_multilevel::BbknnProposer`] and pinto's
752/// `GraphProposer` — both proposers differ only in how they gather
753/// `neighbor_groups`, not in how they combine with siblings.
754///
755/// * `siblings` — sorted, deduped sibling group list.
756/// * `neighbor_groups` — sorted, deduped group list from the proposer's
757///   spatial/structural neighborhood.
758/// * `current` — the entity's current label; appended if the intersection
759///   excluded it.
760pub fn intersect_with_siblings_fallback(
761    siblings: &[usize],
762    neighbor_groups: &[usize],
763    current: usize,
764) -> Vec<usize> {
765    if siblings.is_empty() {
766        return Vec::new();
767    }
768    if siblings.len() == 1 {
769        return siblings.to_vec();
770    }
771    let intersect: Vec<usize> = siblings
772        .iter()
773        .copied()
774        .filter(|g| neighbor_groups.binary_search(g).is_ok())
775        .collect();
776    if intersect.is_empty() {
777        return siblings.to_vec();
778    }
779    if intersect.contains(&current) {
780        intersect
781    } else {
782        let mut c = intersect;
783        c.push(current);
784        c.sort_unstable();
785        c
786    }
787}
788
789///////////////////
790// Sweep drivers //
791///////////////////
792
793/// Refine `labels` in place for one level by running Gibbs + greedy sweeps
794/// over `profiles` with the given pre-built `candidates` and a custom
795/// [`MoveGuard`]. Returns total accepted moves across all sweeps.
796///
797/// A proposed move `from → to` is applied only when `guard.accept_move(...)`
798/// returns `true`. In Gibbs sweeps, a vetoed move leaves the entity in its
799/// current group for this sweep. Greedy sweeps likewise skip the move.
800///
801/// Shared sweep-driver context bundling the readonly inputs that every
802/// `refine_with_*` variant needs. Kept as a struct so the driver fns don't
803/// exceed clippy's argument-count threshold.
804pub struct RefineContext<'a> {
805    pub profiles: &'a Profiles,
806    pub k: usize,
807    pub params: &'a RefineParams,
808    pub level_label: &'a str,
809}
810
811/// Outcome of one sweep over all entities.
812#[derive(Default, Clone, Copy)]
813struct SweepCounts {
814    moves: usize,
815    vetoed: usize,
816}
817
818/// Apply a precomputed `proposals` vector to `stats` sequentially, consulting
819/// the move guard. Shared by both Jacobi sweeps after the parallel proposal
820/// phase. The guard sees the membership-being-updated, matching the
821/// sequential semantics.
822fn apply_proposals<G: MoveGuard>(
823    proposals: &[usize],
824    stats: &mut DcPoissonStats,
825    profiles: &Profiles,
826    guard: &G,
827) -> SweepCounts {
828    let mut sc = SweepCounts::default();
829    for (e, &new) in proposals.iter().enumerate() {
830        let old = stats.membership[e];
831        if new == old {
832            continue;
833        }
834        if guard.accept_move(e, old, &stats.membership) {
835            stats.delta_move(e, old, new, profiles);
836            sc.moves += 1;
837        } else {
838            sc.vetoed += 1;
839        }
840    }
841    sc
842}
843
844/// Gauss–Seidel sequential sweep. For each entity visited in `order`, scores
845/// the candidate destinations under the live (mutated) stats, picks one via
846/// `pick(log_probs, candidates, current_label)`, and applies the move through
847/// the guard. Gibbs callers shuffle `order` and pass a categorical-sampling
848/// closure; greedy callers pass `0..n` and an argmax closure.
849fn sweep_sequential<G, F, I>(
850    candidates: &[Vec<usize>],
851    stats: &mut DcPoissonStats,
852    profiles: &Profiles,
853    guard: &G,
854    log_probs: &mut [f64],
855    order: I,
856    mut pick: F,
857) -> SweepCounts
858where
859    G: MoveGuard,
860    F: FnMut(&[f64], &[usize], usize) -> usize,
861    I: IntoIterator<Item = usize>,
862{
863    let mut sc = SweepCounts::default();
864    for e in order {
865        let cand = &candidates[e];
866        if cand.len() < 2 {
867            continue;
868        }
869        compute_log_probs_restricted(e, stats, profiles, cand, log_probs);
870        let old = stats.membership[e];
871        let new = pick(log_probs, cand, old);
872        if new == old {
873            continue;
874        }
875        if guard.accept_move(e, old, &stats.membership) {
876            stats.delta_move(e, old, new, profiles);
877            sc.moves += 1;
878        } else {
879            sc.vetoed += 1;
880        }
881    }
882    sc
883}
884
885/// Jacobi sweep: every entity computes its proposal against a frozen pre-sweep
886/// snapshot of `stats` (rayon `par_iter`); proposals are then applied
887/// sequentially with the move guard.
888///
889/// `pick(log_probs, cand, e, current)` chooses the destination given the
890/// restricted log-likelihoods. For Gibbs it derives a per-entity RNG from
891/// `(sweep_seed, e)` and samples categorically; for greedy it ignores `e`
892/// and argmaxes.
893/// `proposals` is a reusable scratch buffer of length `candidates.len()` —
894/// hoisted by the driver across sweeps to avoid re-allocating.
895fn sweep_jacobi<G: MoveGuard, F>(
896    candidates: &[Vec<usize>],
897    stats: &mut DcPoissonStats,
898    profiles: &Profiles,
899    guard: &G,
900    k: usize,
901    proposals: &mut [usize],
902    pick: F,
903) -> SweepCounts
904where
905    F: Fn(&[f64], &[usize], usize, usize) -> usize + Sync,
906{
907    debug_assert_eq!(proposals.len(), candidates.len());
908    {
909        // Immutable reborrow so the rayon closure can capture `&stats: Sync`
910        // while the outer `&mut` is held across the propose / apply phases.
911        let stats: &DcPoissonStats = stats;
912        proposals
913            .par_iter_mut()
914            .enumerate()
915            .with_min_len(256)
916            .for_each_init(
917                || vec![f64::NEG_INFINITY; k],
918                |log_probs, (e, prop)| {
919                    let cand = &candidates[e];
920                    let current = stats.membership[e];
921                    if cand.len() < 2 {
922                        *prop = current;
923                        return;
924                    }
925                    compute_log_probs_restricted(e, stats, profiles, cand, log_probs);
926                    *prop = pick(log_probs, cand, e, current);
927                },
928            );
929    }
930    apply_proposals(proposals, stats, profiles, guard)
931}
932
933/// This is the lowest-level driver. Pass [`NoGuard`] to get the unguarded
934/// behavior; most callers should prefer the convenience wrappers
935/// [`refine_with_candidates`] / [`refine_with_proposer`] /
936/// [`refine_with_proposer_guarded`].
937///
938/// Sweep dispatch follows `ctx.params.parallel`: Jacobi (parallel proposal,
939/// sequential apply) when true, Gauss–Seidel (single-threaded) when false.
940/// See [`RefineParams::parallel`] for trade-offs.
941pub fn refine_with_candidates_guarded<G: MoveGuard>(
942    labels: &mut [usize],
943    candidates: &[Vec<usize>],
944    guard: &G,
945    rng: &mut SmallRng,
946    ctx: &RefineContext,
947) -> usize {
948    let RefineContext {
949        profiles,
950        k,
951        params,
952        level_label,
953    } = *ctx;
954    let num_entities = labels.len();
955    let mut stats = DcPoissonStats::from_profiles(profiles, k, labels);
956    let mut log_probs = vec![f64::NEG_INFINITY; k];
957    let mut total_moves = 0usize;
958    let mut total_vetoed = 0usize;
959
960    // Reusable proposal scratch for Jacobi sweeps — hoisted so the
961    // num_sweeps × num_entities allocation isn't repeated each sweep.
962    let mut proposals: Vec<usize> = if params.parallel {
963        vec![0usize; num_entities]
964    } else {
965        Vec::new()
966    };
967
968    // Reusable shuffled-order buffer for sequential Gibbs sweeps. Greedy
969    // sequential iterates `0..num_entities` directly and ignores this.
970    let mut order: Vec<usize> = if !params.parallel {
971        (0..num_entities).collect()
972    } else {
973        Vec::new()
974    };
975
976    let max_sweeps = (params.num_gibbs + params.num_greedy) as u64;
977    let prog_bar = legume_numeric::matrix::progress::new_progress_bar(max_sweeps)
978        .with_message(format!("{level_label} sweeps"));
979    // Refresh on a timer so the bar visibly animates between sweep ticks
980    // (a single sweep at large D can take several seconds — without this
981    // the bar is silent until the next inc(1)).
982    prog_bar.enable_steady_tick(std::time::Duration::from_millis(100));
983
984    // Jacobi per-sweep seed base: draw a single odd u64 from `rng` so the
985    // sweep loop is reproducible given `params.seed` regardless of how many
986    // cells `shuffle` would have consumed in the sequential path.
987    let jacobi_base_seed = rng.random::<u64>() | 1;
988
989    if params.num_gibbs > 0 {
990        let mut low_sweeps = 0usize;
991        for sweep in 0..params.num_gibbs {
992            let sc = if params.parallel {
993                let sweep_seed = jacobi_base_seed.wrapping_mul(sweep as u64 + 1);
994                sweep_jacobi(
995                    candidates,
996                    &mut stats,
997                    profiles,
998                    guard,
999                    k,
1000                    &mut proposals,
1001                    |log_probs, cand, e, current| {
1002                        let vertex_seed = sweep_seed ^ (e as u64).wrapping_mul(2654435761);
1003                        let mut rng = SmallRng::seed_from_u64(vertex_seed);
1004                        sample_categorical_log_restricted(log_probs, cand, current, &mut rng)
1005                    },
1006                )
1007            } else {
1008                order.shuffle(rng);
1009                sweep_sequential(
1010                    candidates,
1011                    &mut stats,
1012                    profiles,
1013                    guard,
1014                    &mut log_probs,
1015                    order.iter().copied(),
1016                    |log_probs, cand, current| {
1017                        sample_categorical_log_restricted(log_probs, cand, current, rng)
1018                    },
1019                )
1020            };
1021            total_moves += sc.moves;
1022            total_vetoed += sc.vetoed;
1023            prog_bar.inc(1);
1024            if params.gibbs_stagnation > 0.0 {
1025                if (sc.moves as f64) < params.gibbs_stagnation * (num_entities as f64) {
1026                    low_sweeps += 1;
1027                    if low_sweeps >= 3 {
1028                        break;
1029                    }
1030                } else {
1031                    low_sweeps = 0;
1032                }
1033            }
1034        }
1035    }
1036
1037    for _sweep in 0..params.num_greedy {
1038        let sc = if params.parallel {
1039            sweep_jacobi(
1040                candidates,
1041                &mut stats,
1042                profiles,
1043                guard,
1044                k,
1045                &mut proposals,
1046                |log_probs, cand, _e, current| argmax_log_restricted_from(log_probs, cand, current),
1047            )
1048        } else {
1049            sweep_sequential(
1050                candidates,
1051                &mut stats,
1052                profiles,
1053                guard,
1054                &mut log_probs,
1055                0..num_entities,
1056                argmax_log_restricted_from,
1057            )
1058        };
1059        total_moves += sc.moves;
1060        total_vetoed += sc.vetoed;
1061        prog_bar.inc(1);
1062        if sc.moves == 0 {
1063            break;
1064        }
1065    }
1066    prog_bar.finish_and_clear();
1067
1068    if total_vetoed > 0 {
1069        log::debug!(
1070            "{}: {} moves, {} vetoed by MoveGuard",
1071            level_label,
1072            total_moves,
1073            total_vetoed
1074        );
1075    }
1076
1077    labels.copy_from_slice(&stats.membership);
1078    total_moves
1079}
1080
1081/// Unguarded variant of [`refine_with_candidates_guarded`].
1082pub fn refine_with_candidates(
1083    labels: &mut [usize],
1084    candidates: &[Vec<usize>],
1085    rng: &mut SmallRng,
1086    ctx: &RefineContext,
1087) -> usize {
1088    refine_with_candidates_guarded(labels, candidates, &NoGuard, rng, ctx)
1089}
1090
1091/// Generic driver: ask `proposer` for candidates, then run the guarded sweep.
1092pub fn refine_with_proposer_guarded<P: CandidateProposer, G: MoveGuard>(
1093    labels: &mut [usize],
1094    proposer: &P,
1095    guard: &G,
1096    rng: &mut SmallRng,
1097    ctx: &RefineContext,
1098) -> usize {
1099    let candidates = proposer.propose(labels);
1100    let moves = refine_with_candidates_guarded(labels, &candidates, guard, rng, ctx);
1101    info!("  {}: {} DC-Poisson moves", ctx.level_label, moves);
1102    moves
1103}
1104
1105/// Unguarded generic driver: ask `proposer` for candidates, then run the
1106/// sweep with [`NoGuard`].
1107pub fn refine_with_proposer<P: CandidateProposer>(
1108    labels: &mut [usize],
1109    proposer: &P,
1110    rng: &mut SmallRng,
1111    ctx: &RefineContext,
1112) -> usize {
1113    let candidates = proposer.propose(labels);
1114    let moves = refine_with_candidates(labels, &candidates, rng, ctx);
1115    info!("  {}: {} DC-Poisson moves", ctx.level_label, moves);
1116    moves
1117}
1118
1119////////////////////////////
1120// Tests (algorithm core) //
1121////////////////////////////
1122
1123#[cfg(test)]
1124#[path = "dc_poisson_tests.rs"]
1125mod tests;