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        // Parallel fold over rows: each worker accumulates into its own
260        // `SparseRunningStatistics`, then we reduce via `merge`. Avoids the
261        // single-threaded `for row in &self.rows` over ~10⁴–10⁵ pb-samples.
262        let num_features = self.num_features;
263        let stats = self
264            .rows
265            .par_iter()
266            .fold(
267                || {
268                    (
269                        SparseRunningStatistics::<f32>::new(num_features),
270                        Vec::<usize>::new(),
271                        Vec::<f32>::new(),
272                    )
273                },
274                |(mut acc, mut col_rows, mut col_vals), row| {
275                    col_rows.clear();
276                    col_vals.clear();
277                    for &(g, v) in row {
278                        col_rows.push(g as usize);
279                        col_vals.push(v);
280                    }
281                    acc.add_sparse_column(&col_rows, &col_vals);
282                    (acc, col_rows, col_vals)
283                },
284            )
285            .map(|(acc, _, _)| acc)
286            .reduce(
287                || SparseRunningStatistics::<f32>::new(num_features),
288                |mut a, b| {
289                    a.merge(&b);
290                    a
291                },
292            );
293
294        // `π_g = sum[g] / Σ sum` is derived directly from the stats without
295        // a second sparse traversal. `mean_g` uses the entity-count denominator
296        // that `SparseRunningStatistics` already provides.
297        let trend = DispersionTrend::from_sparse_stats(&stats);
298        let means = stats.mean();
299        let sums = stats.sum();
300        let total_mass: f64 = sums.iter().map(|&s| s as f64).sum();
301        let avg_s = if self.num_entities > 0 {
302            (total_mass / self.num_entities as f64) as f32
303        } else {
304            1.0
305        };
306        let inv_total = if total_mass > 0.0 {
307            1.0 / total_mass as f32
308        } else {
309            0.0
310        };
311        (0..self.num_features)
312            .map(|g| trend.fisher_weight(sums[g] * inv_total, avg_s, means[g]))
313            .collect()
314    }
315}
316
317///////////////////////////////////
318// Poisson sufficient statistics //
319///////////////////////////////////
320
321/// Sufficient statistics with cached log quantities for O(K · nnz(row)) scoring.
322///
323/// `gene_sum[g·K + k]` = Σ_{e : z_e = k} y_eg; `size_sum[k]` = Σ_{e : z_e = k} s_e.
324/// The per-feature tables are laid out **feature-major** (`g·K + k`): scoring
325/// one entity walks its nonzero features once and, for each, reads the `K`
326/// group values side by side, instead of striding across a `K × M` table
327/// once per candidate group. Use [`DcPoissonStats::idx`] to address them.
328/// Log caches score every non-current block with no log; the current block's
329/// leave-one-out score takes one `fast_ln` per nonzero feature, and
330/// `delta_move` refreshes the logs of the columns a move touches.
331///
332/// Log caches are `f32` — values live in roughly `[-20, +25]` (dominated by
333/// `-ln(LOG_EPS)` at the floor), and scoring is noise-dominated so f32's
334/// ~7-digit precision is ample. Halves the largest allocation versus f64.
335#[derive(Clone)]
336pub struct DcPoissonStats {
337    pub k: usize,
338    pub num_features: usize,
339    pub membership: Vec<usize>,
340    /// Feature-major `M × K`: entry `g·K + k`.
341    pub gene_sum: Vec<f64>,
342    pub size_sum: Vec<f64>,
343    /// Feature-major `M × K`: entry `g·K + k`.
344    pub log_gene: Vec<f32>,
345    pub log_size_offset: Vec<f32>,
346}
347
348impl DcPoissonStats {
349    /// Index of (group `k`, feature `g`) in `gene_sum` / `log_gene`.
350    #[inline(always)]
351    pub fn idx(&self, k: usize, g: usize) -> usize {
352        g * self.k + k
353    }
354
355    pub fn from_profiles(profiles: &Profiles, k: usize, membership: &[usize]) -> Self {
356        let m = profiles.num_features;
357        let mut gene_sum = vec![0f64; k * m];
358        let mut size_sum = vec![0f64; k];
359        for (e, row) in profiles.rows.iter().enumerate() {
360            let z = membership[e];
361            assert!(z < k, "membership[{}]={} out of range 0..{}", e, z, k);
362            for &(g, v) in row {
363                gene_sum[g as usize * k + z] += v as f64;
364            }
365            size_sum[z] += profiles.size_factor[e] as f64;
366        }
367        let log_gene: Vec<f32> = gene_sum
368            .iter()
369            .map(|&s| fast_ln(s + LOG_EPS) as f32)
370            .collect();
371        let m_eps = m as f64 * LOG_EPS;
372        let log_size_offset: Vec<f32> = size_sum
373            .iter()
374            .map(|&s| -fast_ln(s + m_eps) as f32)
375            .collect();
376        Self {
377            k,
378            num_features: m,
379            membership: membership.to_vec(),
380            gene_sum,
381            size_sum,
382            log_gene,
383            log_size_offset,
384        }
385    }
386
387    /// Apply an entity's reassignment and refresh only the affected log entries.
388    pub fn delta_move(&mut self, e: usize, k_from: usize, k_to: usize, profiles: &Profiles) {
389        if k_from == k_to {
390            return;
391        }
392        let kk = self.k;
393        let m = self.num_features;
394        let m_eps = m as f64 * LOG_EPS;
395
396        // Subtractions clamp at zero: the running sums accumulate rounding
397        // residues, and a block drained back to (true) zero can otherwise be
398        // left with a negative residue larger than LOG_EPS — feeding
399        // `ln(negative) = NaN` into the log caches.
400        for &(g, v) in &profiles.rows[e] {
401            let base = g as usize * kk;
402            let (i_from, i_to) = (base + k_from, base + k_to);
403            self.gene_sum[i_from] = (self.gene_sum[i_from] - v as f64).max(0.0);
404            self.gene_sum[i_to] += v as f64;
405            self.log_gene[i_from] = fast_ln(self.gene_sum[i_from] + LOG_EPS) as f32;
406            self.log_gene[i_to] = fast_ln(self.gene_sum[i_to] + LOG_EPS) as f32;
407        }
408        let sf = profiles.size_factor[e] as f64;
409        self.size_sum[k_from] = (self.size_sum[k_from] - sf).max(0.0);
410        self.size_sum[k_to] += sf;
411        self.log_size_offset[k_from] = -fast_ln(self.size_sum[k_from] + m_eps) as f32;
412        self.log_size_offset[k_to] = -fast_ln(self.size_sum[k_to] + m_eps) as f32;
413
414        self.membership[e] = k_to;
415    }
416
417    /// Full recompute from current `membership` (slow path, used for tests).
418    #[cfg(test)]
419    fn recompute(&mut self, profiles: &Profiles) {
420        let k = self.k;
421        let m = self.num_features;
422        self.gene_sum.iter_mut().for_each(|x| *x = 0.0);
423        self.size_sum.iter_mut().for_each(|x| *x = 0.0);
424        for (e, row) in profiles.rows.iter().enumerate() {
425            let z = self.membership[e];
426            for &(g, v) in row {
427                self.gene_sum[g as usize * k + z] += v as f64;
428            }
429            self.size_sum[z] += profiles.size_factor[e] as f64;
430        }
431        for i in 0..k * m {
432            self.log_gene[i] = fast_ln(self.gene_sum[i] + LOG_EPS) as f32;
433        }
434        let m_eps = m as f64 * LOG_EPS;
435        for i in 0..k {
436            self.log_size_offset[i] = -fast_ln(self.size_sum[i] + m_eps) as f32;
437        }
438    }
439}
440
441/////////////////////
442// Scoring kernels //
443/////////////////////
444
445/// Score entity `e` for one destination `k` (Poisson plug-in MAP, up to a
446/// common constant):
447/// `  s(e, k) = Σ_{g : y_eg > 0} y_eg · ln(gene_sum⁻ᵉ[k, g] + ε) − size_factor[e] · ln(size_sum⁻ᵉ[k] + Mε)`
448///
449/// where `⁻ᵉ` means entity `e`'s own contribution is excluded. Candidate
450/// blocks never contain `e`, so they read straight from the log caches; the
451/// entity's *current* block subtracts `e`'s row before taking logs
452/// (leave-one-out). Scoring the current block with `e` included would give
453/// "stay put" a self-inclusion bonus that grows with the entity's size
454/// factor and shrinks with block mass — anchoring large entities in place.
455///
456/// Reference for [`compute_log_probs_restricted`], which scores all allowed
457/// blocks in one pass with the same per-block summation order (so the two
458/// agree bit for bit).
459#[cfg(test)]
460fn score_move(e: usize, k: usize, stats: &DcPoissonStats, profiles: &Profiles) -> f64 {
461    let m = stats.num_features;
462    let sf = profiles.size_factor[e] as f64;
463    let row = &profiles.rows[e];
464    if stats.membership[e] == k {
465        let m_eps = m as f64 * LOG_EPS;
466        let loo_size = (stats.size_sum[k] - sf).max(0.0);
467        let mut acc = -sf * fast_ln(loo_size + m_eps);
468        for &(g, v) in row {
469            let vg = v as f64;
470            let loo = (stats.gene_sum[stats.idx(k, g as usize)] - vg).max(0.0);
471            acc += vg * fast_ln(loo + LOG_EPS);
472        }
473        acc
474    } else {
475        let mut acc = sf * stats.log_size_offset[k] as f64;
476        for &(g, v) in row {
477            acc += v as f64 * stats.log_gene[stats.idx(k, g as usize)] as f64;
478        }
479        acc
480    }
481}
482
483/// Log-probability of placing entity `e` into each of the `allowed` blocks.
484///
485/// `  s(e, k) = Σ_{g : y_eg > 0} y_eg · ln(gene_sum⁻ᵉ[k, g] + ε) − size_factor[e] · ln(size_sum⁻ᵉ[k] + Mε)`
486///
487/// `⁻ᵉ` means entity `e`'s own contribution is excluded: candidate blocks
488/// never contain `e` and read the log caches, while `e`'s *current* block
489/// subtracts `e`'s row before taking logs (leave-one-out) — otherwise
490/// "stay put" earns a self-inclusion bonus that anchors large entities.
491///
492/// All blocks are scored in one pass over `e`'s nonzero features: per
493/// feature, the `K` cached values sit side by side (feature-major layout),
494/// so the pass touches one short contiguous run per feature. Each block's
495/// sum is still accumulated in feature order, the same order as scoring the
496/// blocks one at a time, so the scores match that bit for bit. Partial
497/// candidate lists accumulate by *position* in `allowed`, so a block listed
498/// twice gets the same, correct score in both places.
499///
500/// Only the `allowed` slots of `log_probs` are written, unless `allowed`
501/// is as long as `K`, in which case the first `K` slots are written.
502/// The rest keep whatever stale values a previous call left (the caller
503/// reuses one buffer across sweeps). Downstream consumers
504/// ([`sample_categorical_log_restricted`], [`argmax_log_restricted`]) read
505/// the `allowed` slots exclusively.
506pub fn compute_log_probs_restricted(
507    e: usize,
508    stats: &DcPoissonStats,
509    profiles: &Profiles,
510    allowed: &[usize],
511    log_probs: &mut [f64],
512) {
513    let kk = stats.k;
514    let m = stats.num_features;
515    let sf = profiles.size_factor[e] as f64;
516    let row = &profiles.rows[e];
517    let cur = stats.membership[e];
518    // Every block 0..K is scored straight into `log_probs`, so repeats in
519    // `allowed` (which then misses some block) are harmless.
520    let dense = allowed.len() == kk;
521    let score_cur = dense || allowed.contains(&cur);
522
523    // Leave-one-out score of the current block.
524    let mut cur_acc = if score_cur {
525        let loo_size = (stats.size_sum[cur] - sf).max(0.0);
526        -sf * fast_ln(loo_size + m as f64 * LOG_EPS)
527    } else {
528        0.0
529    };
530    let mut add_cur = |base: usize, vg: f64| {
531        if score_cur {
532            let loo = (stats.gene_sum[base + cur] - vg).max(0.0);
533            cur_acc += vg * fast_ln(loo + LOG_EPS);
534        }
535    };
536
537    // Cached (non-leave-one-out) scores for every allowed block, including
538    // the current one, which is overwritten at the end.
539    if dense {
540        for (slot, &o) in log_probs[..kk].iter_mut().zip(&stats.log_size_offset) {
541            *slot = sf * o as f64;
542        }
543        for &(g, v) in row {
544            let vg = v as f64;
545            let base = g as usize * kk;
546            let logs = &stats.log_gene[base..base + kk];
547            for (slot, &l) in log_probs[..kk].iter_mut().zip(logs) {
548                *slot += vg * l as f64;
549            }
550            add_cur(base, vg);
551        }
552    } else {
553        thread_local! {
554            static SCRATCH: std::cell::RefCell<Vec<f64>> = const { std::cell::RefCell::new(Vec::new()) };
555        }
556        SCRATCH.with(|scratch| {
557            let mut scratch = scratch.borrow_mut();
558            scratch.clear();
559            scratch.extend(
560                allowed
561                    .iter()
562                    .map(|&k| sf * stats.log_size_offset[k] as f64),
563            );
564            for &(g, v) in row {
565                let vg = v as f64;
566                let base = g as usize * kk;
567                let logs = &stats.log_gene[base..base + kk];
568                for (acc, &k) in scratch.iter_mut().zip(allowed) {
569                    *acc += vg * logs[k] as f64;
570                }
571                add_cur(base, vg);
572            }
573            for (&acc, &k) in scratch.iter().zip(allowed) {
574                log_probs[k] = acc;
575            }
576        });
577    }
578    if score_cur {
579        log_probs[cur] = cur_acc;
580    }
581}
582
583/// Unrestricted variant used only from tests.
584#[cfg(test)]
585fn compute_log_probs(e: usize, stats: &DcPoissonStats, profiles: &Profiles, log_probs: &mut [f64]) {
586    for (k, slot) in log_probs.iter_mut().enumerate().take(stats.k) {
587        *slot = score_move(e, k, stats, profiles);
588    }
589}
590
591/// Gumbel-max categorical sampling over the `allowed` slots of `log_probs`,
592/// skipping non-finite entries. Falls back to `current` (the entity's
593/// present label — always a legal "move") when no allowed slot is finite,
594/// so a degenerate score vector can never produce an out-of-range label.
595pub fn sample_categorical_log_restricted(
596    log_probs: &[f64],
597    allowed: &[usize],
598    current: usize,
599    rng: &mut SmallRng,
600) -> usize {
601    let mut best_key = f64::NEG_INFINITY;
602    let mut best_idx = current;
603    for &k in allowed {
604        let lp = log_probs[k];
605        if lp.is_finite() {
606            let u: f64 = rng.random_range(1e-12..1.0_f64);
607            let g = -(-u.ln()).ln();
608            let key = lp + g;
609            if key > best_key {
610                best_key = key;
611                best_idx = k;
612            }
613        }
614    }
615    best_idx
616}
617
618/// Argmax over `allowed` indices.
619pub fn argmax_log_restricted(log_probs: &[f64], allowed: &[usize]) -> usize {
620    let mut best = allowed[0];
621    let mut best_val = log_probs[best];
622    for &k in &allowed[1..] {
623        if log_probs[k] > best_val {
624            best = k;
625            best_val = log_probs[k];
626        }
627    }
628    best
629}
630
631////////////////////////////////////
632// Hierarchy / relabeling helpers //
633////////////////////////////////////
634
635/// Compact a label vector to dense `0..K` ids, preserving relative order of
636/// first appearance. Returns the new labels and the new `K`. Generic over
637/// the key type so it serves both plain group ids (`usize`) and composite
638/// keys (e.g. `(child, parent)` pairs in `project_to_refinement`).
639pub fn compact_labels<K>(labels: &[K]) -> (Vec<usize>, usize)
640where
641    K: Copy + Eq + std::hash::Hash,
642{
643    let mut map: HashMap<K, usize> = HashMap::default();
644    let mut next = 0usize;
645    let mut out = Vec::with_capacity(labels.len());
646    for &g in labels {
647        let new = *map.entry(g).or_insert_with(|| {
648            let id = next;
649            next += 1;
650            id
651        });
652        out.push(new);
653    }
654    (out, next)
655}
656
657/// For each entity, siblings at `level` = groups at `level` sharing the
658/// same parent group at `level + 1`.
659///
660/// `refined` follows the finest → coarsest convention: `refined[0]` is the
661/// finest level, `refined[refined.len() - 1]` is the coarsest. At the
662/// coarsest level (index `num_levels - 1`), the virtual root groups every
663/// entity together → siblings = all groups.
664pub fn compute_sibling_sets(
665    refined: &[Vec<usize>],
666    level: usize,
667    num_groups_at_level: usize,
668) -> Vec<Vec<usize>> {
669    let num_levels = refined.len();
670    let num_entities = refined[level].len();
671
672    if level + 1 >= num_levels {
673        let all_groups: Vec<usize> = (0..num_groups_at_level).collect();
674        return vec![all_groups; num_entities];
675    }
676
677    let mut parent_to_children: HashMap<usize, Vec<usize>> = HashMap::default();
678    for (child, parent) in refined[level].iter().zip(refined[level + 1].iter()) {
679        let entry = parent_to_children.entry(*parent).or_default();
680        if !entry.contains(child) {
681            entry.push(*child);
682        }
683    }
684    for v in parent_to_children.values_mut() {
685        v.sort_unstable();
686    }
687
688    (0..num_entities)
689        .map(|e| {
690            let parent = refined[level + 1][e];
691            parent_to_children.get(&parent).cloned().unwrap_or_default()
692        })
693        .collect()
694}
695
696////////////////////////////////////////////
697// Candidate proposer + move guard traits //
698////////////////////////////////////////////
699
700/// Propose candidate group labels each entity may move into on one sweep.
701///
702/// The returned slice `candidates[e]` MUST include `labels[e]` (staying put
703/// is always legal) and MUST index into the current 0..k label space.
704pub trait CandidateProposer {
705    fn propose(&self, labels: &[usize]) -> Vec<Vec<usize>>;
706}
707
708/// Veto individual moves after they've been picked by the sweep.
709///
710/// Called once per *proposed* accepted move (`from != to`) before the move
711/// is applied to the sufficient statistics. The destination label is
712/// intentionally not passed — guards so far only need the source cluster
713/// (e.g. articulation tests ask "does removing this entity disconnect
714/// `from`?"). Add a destination-aware variant if a concrete use case
715/// surfaces.
716pub trait MoveGuard {
717    fn accept_move(&self, entity: usize, from: usize, labels: &[usize]) -> bool;
718}
719
720/// Trivial guard that accepts every move. Zero-cost default used by the
721/// unguarded [`refine_with_candidates`] and [`refine_with_proposer`].
722pub struct NoGuard;
723
724impl MoveGuard for NoGuard {
725    #[inline(always)]
726    fn accept_move(&self, _e: usize, _from: usize, _labels: &[usize]) -> bool {
727        true
728    }
729}
730
731/// Intersect a per-entity sibling set with a per-entity neighbor-group set,
732/// with sibling fallback when the intersection is empty and guaranteed
733/// inclusion of the entity's current label so staying put is always a
734/// legal move.
735///
736/// Shared between [`crate::alg::refine_multilevel::BbknnProposer`] and pinto's
737/// `GraphProposer` — both proposers differ only in how they gather
738/// `neighbor_groups`, not in how they combine with siblings.
739///
740/// * `siblings` — sorted, deduped sibling group list.
741/// * `neighbor_groups` — sorted, deduped group list from the proposer's
742///   spatial/structural neighborhood.
743/// * `current` — the entity's current label; appended if the intersection
744///   excluded it.
745pub fn intersect_with_siblings_fallback(
746    siblings: &[usize],
747    neighbor_groups: &[usize],
748    current: usize,
749) -> Vec<usize> {
750    if siblings.is_empty() {
751        return Vec::new();
752    }
753    if siblings.len() == 1 {
754        return siblings.to_vec();
755    }
756    let intersect: Vec<usize> = siblings
757        .iter()
758        .copied()
759        .filter(|g| neighbor_groups.binary_search(g).is_ok())
760        .collect();
761    if intersect.is_empty() {
762        return siblings.to_vec();
763    }
764    if intersect.contains(&current) {
765        intersect
766    } else {
767        let mut c = intersect;
768        c.push(current);
769        c.sort_unstable();
770        c
771    }
772}
773
774///////////////////
775// Sweep drivers //
776///////////////////
777
778/// Refine `labels` in place for one level by running Gibbs + greedy sweeps
779/// over `profiles` with the given pre-built `candidates` and a custom
780/// [`MoveGuard`]. Returns total accepted moves across all sweeps.
781///
782/// A proposed move `from → to` is applied only when `guard.accept_move(...)`
783/// returns `true`. In Gibbs sweeps, a vetoed move leaves the entity in its
784/// current group for this sweep. Greedy sweeps likewise skip the move.
785///
786/// Shared sweep-driver context bundling the readonly inputs that every
787/// `refine_with_*` variant needs. Kept as a struct so the driver fns don't
788/// exceed clippy's argument-count threshold.
789pub struct RefineContext<'a> {
790    pub profiles: &'a Profiles,
791    pub k: usize,
792    pub params: &'a RefineParams,
793    pub level_label: &'a str,
794}
795
796/// Outcome of one sweep over all entities.
797#[derive(Default, Clone, Copy)]
798struct SweepCounts {
799    moves: usize,
800    vetoed: usize,
801}
802
803/// Apply a precomputed `proposals` vector to `stats` sequentially, consulting
804/// the move guard. Shared by both Jacobi sweeps after the parallel proposal
805/// phase. The guard sees the membership-being-updated, matching the
806/// sequential semantics.
807fn apply_proposals<G: MoveGuard>(
808    proposals: &[usize],
809    stats: &mut DcPoissonStats,
810    profiles: &Profiles,
811    guard: &G,
812) -> SweepCounts {
813    let mut sc = SweepCounts::default();
814    for (e, &new) in proposals.iter().enumerate() {
815        let old = stats.membership[e];
816        if new == old {
817            continue;
818        }
819        if guard.accept_move(e, old, &stats.membership) {
820            stats.delta_move(e, old, new, profiles);
821            sc.moves += 1;
822        } else {
823            sc.vetoed += 1;
824        }
825    }
826    sc
827}
828
829/// Gauss–Seidel sequential sweep. For each entity visited in `order`, scores
830/// the candidate destinations under the live (mutated) stats, picks one via
831/// `pick(log_probs, candidates, current_label)`, and applies the move through
832/// the guard. Gibbs callers shuffle `order` and pass a categorical-sampling
833/// closure; greedy callers pass `0..n` and an argmax closure.
834fn sweep_sequential<G, F, I>(
835    candidates: &[Vec<usize>],
836    stats: &mut DcPoissonStats,
837    profiles: &Profiles,
838    guard: &G,
839    log_probs: &mut [f64],
840    order: I,
841    mut pick: F,
842) -> SweepCounts
843where
844    G: MoveGuard,
845    F: FnMut(&[f64], &[usize], usize) -> usize,
846    I: IntoIterator<Item = usize>,
847{
848    let mut sc = SweepCounts::default();
849    for e in order {
850        let cand = &candidates[e];
851        if cand.len() < 2 {
852            continue;
853        }
854        compute_log_probs_restricted(e, stats, profiles, cand, log_probs);
855        let old = stats.membership[e];
856        let new = pick(log_probs, cand, old);
857        if new == old {
858            continue;
859        }
860        if guard.accept_move(e, old, &stats.membership) {
861            stats.delta_move(e, old, new, profiles);
862            sc.moves += 1;
863        } else {
864            sc.vetoed += 1;
865        }
866    }
867    sc
868}
869
870/// Jacobi sweep: every entity computes its proposal against a frozen pre-sweep
871/// snapshot of `stats` (rayon `par_iter`); proposals are then applied
872/// sequentially with the move guard.
873///
874/// `pick(log_probs, cand, e, current)` chooses the destination given the
875/// restricted log-likelihoods. For Gibbs it derives a per-entity RNG from
876/// `(sweep_seed, e)` and samples categorically; for greedy it ignores `e`
877/// and argmaxes.
878/// `proposals` is a reusable scratch buffer of length `candidates.len()` —
879/// hoisted by the driver across sweeps to avoid re-allocating.
880fn sweep_jacobi<G: MoveGuard, F>(
881    candidates: &[Vec<usize>],
882    stats: &mut DcPoissonStats,
883    profiles: &Profiles,
884    guard: &G,
885    k: usize,
886    proposals: &mut [usize],
887    pick: F,
888) -> SweepCounts
889where
890    F: Fn(&[f64], &[usize], usize, usize) -> usize + Sync,
891{
892    debug_assert_eq!(proposals.len(), candidates.len());
893    {
894        // Immutable reborrow so the rayon closure can capture `&stats: Sync`
895        // while the outer `&mut` is held across the propose / apply phases.
896        let stats: &DcPoissonStats = stats;
897        proposals
898            .par_iter_mut()
899            .enumerate()
900            .with_min_len(256)
901            .for_each_init(
902                || vec![f64::NEG_INFINITY; k],
903                |log_probs, (e, prop)| {
904                    let cand = &candidates[e];
905                    let current = stats.membership[e];
906                    if cand.len() < 2 {
907                        *prop = current;
908                        return;
909                    }
910                    compute_log_probs_restricted(e, stats, profiles, cand, log_probs);
911                    *prop = pick(log_probs, cand, e, current);
912                },
913            );
914    }
915    apply_proposals(proposals, stats, profiles, guard)
916}
917
918/// This is the lowest-level driver. Pass [`NoGuard`] to get the unguarded
919/// behavior; most callers should prefer the convenience wrappers
920/// [`refine_with_candidates`] / [`refine_with_proposer`] /
921/// [`refine_with_proposer_guarded`].
922///
923/// Sweep dispatch follows `ctx.params.parallel`: Jacobi (parallel proposal,
924/// sequential apply) when true, Gauss–Seidel (single-threaded) when false.
925/// See [`RefineParams::parallel`] for trade-offs.
926pub fn refine_with_candidates_guarded<G: MoveGuard>(
927    labels: &mut [usize],
928    candidates: &[Vec<usize>],
929    guard: &G,
930    rng: &mut SmallRng,
931    ctx: &RefineContext,
932) -> usize {
933    let RefineContext {
934        profiles,
935        k,
936        params,
937        level_label,
938    } = *ctx;
939    let num_entities = labels.len();
940    let mut stats = DcPoissonStats::from_profiles(profiles, k, labels);
941    let mut log_probs = vec![f64::NEG_INFINITY; k];
942    let mut total_moves = 0usize;
943    let mut total_vetoed = 0usize;
944
945    // Reusable proposal scratch for Jacobi sweeps — hoisted so the
946    // num_sweeps × num_entities allocation isn't repeated each sweep.
947    let mut proposals: Vec<usize> = if params.parallel {
948        vec![0usize; num_entities]
949    } else {
950        Vec::new()
951    };
952
953    // Reusable shuffled-order buffer for sequential Gibbs sweeps. Greedy
954    // sequential iterates `0..num_entities` directly and ignores this.
955    let mut order: Vec<usize> = if !params.parallel {
956        (0..num_entities).collect()
957    } else {
958        Vec::new()
959    };
960
961    let max_sweeps = (params.num_gibbs + params.num_greedy) as u64;
962    let prog_bar = legume_numeric::matrix::progress::new_progress_bar(max_sweeps)
963        .with_message(format!("{level_label} sweeps"));
964    // Refresh on a timer so the bar visibly animates between sweep ticks
965    // (a single sweep at large D can take several seconds — without this
966    // the bar is silent until the next inc(1)).
967    prog_bar.enable_steady_tick(std::time::Duration::from_millis(100));
968
969    // Jacobi per-sweep seed base: draw a single odd u64 from `rng` so the
970    // sweep loop is reproducible given `params.seed` regardless of how many
971    // cells `shuffle` would have consumed in the sequential path.
972    let jacobi_base_seed = rng.random::<u64>() | 1;
973
974    if params.num_gibbs > 0 {
975        let mut low_sweeps = 0usize;
976        for sweep in 0..params.num_gibbs {
977            let sc = if params.parallel {
978                let sweep_seed = jacobi_base_seed.wrapping_mul(sweep as u64 + 1);
979                sweep_jacobi(
980                    candidates,
981                    &mut stats,
982                    profiles,
983                    guard,
984                    k,
985                    &mut proposals,
986                    |log_probs, cand, e, current| {
987                        let vertex_seed = sweep_seed ^ (e as u64).wrapping_mul(2654435761);
988                        let mut rng = SmallRng::seed_from_u64(vertex_seed);
989                        sample_categorical_log_restricted(log_probs, cand, current, &mut rng)
990                    },
991                )
992            } else {
993                order.shuffle(rng);
994                sweep_sequential(
995                    candidates,
996                    &mut stats,
997                    profiles,
998                    guard,
999                    &mut log_probs,
1000                    order.iter().copied(),
1001                    |log_probs, cand, current| {
1002                        sample_categorical_log_restricted(log_probs, cand, current, rng)
1003                    },
1004                )
1005            };
1006            total_moves += sc.moves;
1007            total_vetoed += sc.vetoed;
1008            prog_bar.inc(1);
1009            if params.gibbs_stagnation > 0.0 {
1010                if (sc.moves as f64) < params.gibbs_stagnation * (num_entities as f64) {
1011                    low_sweeps += 1;
1012                    if low_sweeps >= 3 {
1013                        break;
1014                    }
1015                } else {
1016                    low_sweeps = 0;
1017                }
1018            }
1019        }
1020    }
1021
1022    for _sweep in 0..params.num_greedy {
1023        let sc = if params.parallel {
1024            sweep_jacobi(
1025                candidates,
1026                &mut stats,
1027                profiles,
1028                guard,
1029                k,
1030                &mut proposals,
1031                |log_probs, cand, _e, _current| argmax_log_restricted(log_probs, cand),
1032            )
1033        } else {
1034            sweep_sequential(
1035                candidates,
1036                &mut stats,
1037                profiles,
1038                guard,
1039                &mut log_probs,
1040                0..num_entities,
1041                |log_probs, cand, _current| argmax_log_restricted(log_probs, cand),
1042            )
1043        };
1044        total_moves += sc.moves;
1045        total_vetoed += sc.vetoed;
1046        prog_bar.inc(1);
1047        if sc.moves == 0 {
1048            break;
1049        }
1050    }
1051    prog_bar.finish_and_clear();
1052
1053    if total_vetoed > 0 {
1054        log::debug!(
1055            "{}: {} moves, {} vetoed by MoveGuard",
1056            level_label,
1057            total_moves,
1058            total_vetoed
1059        );
1060    }
1061
1062    labels.copy_from_slice(&stats.membership);
1063    total_moves
1064}
1065
1066/// Unguarded variant of [`refine_with_candidates_guarded`].
1067pub fn refine_with_candidates(
1068    labels: &mut [usize],
1069    candidates: &[Vec<usize>],
1070    rng: &mut SmallRng,
1071    ctx: &RefineContext,
1072) -> usize {
1073    refine_with_candidates_guarded(labels, candidates, &NoGuard, rng, ctx)
1074}
1075
1076/// Generic driver: ask `proposer` for candidates, then run the guarded sweep.
1077pub fn refine_with_proposer_guarded<P: CandidateProposer, G: MoveGuard>(
1078    labels: &mut [usize],
1079    proposer: &P,
1080    guard: &G,
1081    rng: &mut SmallRng,
1082    ctx: &RefineContext,
1083) -> usize {
1084    let candidates = proposer.propose(labels);
1085    let moves = refine_with_candidates_guarded(labels, &candidates, guard, rng, ctx);
1086    info!("  {}: {} DC-Poisson moves", ctx.level_label, moves);
1087    moves
1088}
1089
1090/// Unguarded generic driver: ask `proposer` for candidates, then run the
1091/// sweep with [`NoGuard`].
1092pub fn refine_with_proposer<P: CandidateProposer>(
1093    labels: &mut [usize],
1094    proposer: &P,
1095    rng: &mut SmallRng,
1096    ctx: &RefineContext,
1097) -> usize {
1098    let candidates = proposer.propose(labels);
1099    let moves = refine_with_candidates(labels, &candidates, rng, ctx);
1100    info!("  {}: {} DC-Poisson moves", ctx.level_label, moves);
1101    moves
1102}
1103
1104////////////////////////////
1105// Tests (algorithm core) //
1106////////////////////////////
1107
1108#[cfg(test)]
1109#[path = "dc_poisson_tests.rs"]
1110mod tests;