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 partition handed in is kept as a partition: refinement makes its
12//! blocks purer without choosing how many there are or how large. Scores
13//! are built so that a block is not preferred for being large (see
14//! [`DcPoissonStats`]), an entity moves only on evidence beyond the noise
15//! of the block estimates, and member-count limits ([`RefineParams`]) keep
16//! every block near its starting size, small blocks trading members
17//! rather than losing them.
18//!
19//! The name "DC-Poisson" is deliberate: we are *not* inferring a stochastic
20//! block model — the blocks are given externally (by a prior coarsening
21//! step) and we only score membership moves. Compare to the full DC-SBM
22//! where blocks themselves are latent variables.
23//!
24//! Entry points:
25//! - [`refine_with_candidates`] — lowest-level sweep driver given pre-built candidates.
26//! - [`refine_with_proposer`] — generic driver that first asks a [`CandidateProposer`].
27//!
28//! See the `BbknnProposer` in `refine_multilevel` for the data_beans::alg
29//! front-end, and pinto's `GraphProposer` for the spatial-graph front-end.
30
31use log::info;
32use nalgebra::DMatrix;
33use rand::rngs::SmallRng;
34use rand::seq::SliceRandom;
35use rand::{RngExt, SeedableRng};
36use rayon::prelude::*;
37use rustc_hash::FxHashMap as HashMap;
38use std::collections::VecDeque;
39
40/// Additive floor to keep `ln(.)` finite when a block or feature has zero
41/// mass.
42pub const LOG_EPS: f64 = 1e-9;
43
44/// `ln(x)` in `f32`: libm's single-precision log, near half the cost of
45/// the `f64` one. Its error is `f32` rounding (about 1e-7 relative), the
46/// precision the cached block logs are stored at anyway. NaN, infinite and
47/// non-positive inputs keep their usual `ln` values.
48#[inline]
49fn fast_ln(x: f64) -> f64 {
50    f64::from((x as f32).ln())
51}
52
53//////////////////////////
54// Public configuration //
55//////////////////////////
56
57/// Feature representation used to score entity → block moves.
58#[derive(Clone, Debug)]
59pub enum ProfileSource {
60    /// Sparse entity × feature counts (the existing `gene_sums` format —
61    /// the field name is historical; the axis is generic).
62    /// [`FeatureWeighting`] is applied per-feature on the sparse profile.
63    Raw,
64    /// Dense projection: `basis * indicator(entity)` per entity. `basis` is
65    /// `proj_dim × num_cells`-shaped; the per-entity profile is the sum over
66    /// its member cells' projection columns. [`FeatureWeighting`] is skipped
67    /// because the feature axis is no longer count-distributed.
68    Projected { basis: DMatrix<f32> },
69}
70
71/// Per-feature weighting applied to the sparse profile before DC-Poisson
72/// scoring. All variants multiply each nonzero entry `y_{e,f}` by a scalar
73/// `w_f` and recompute per-row size factors; only the formula for `w_f`
74/// differs.
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub enum FeatureWeighting {
77    /// No reweighting: `w_f = 1`. Recovers proper DC-Poisson with entity-level
78    /// degree correction (Karrer–Newman MAP under `Gamma(α, 0)` on `θ`).
79    None,
80    /// Fisher-information weight under the fitted NB trend (genes-as-features
81    /// case): `w_f = 1 / (1 + π_f · s̄ · φ(μ_f))`. Bounded in `(0, 1]`,
82    /// attenuates high-mean / high-dispersion features, recovers
83    /// `w_f = 1` in the Poisson limit (`φ → 0`).
84    FisherInfoNb,
85}
86
87/// Parameters controlling the refinement pass.
88#[derive(Clone, Debug)]
89pub struct RefineParams {
90    /// Gibbs sweeps per level (0 disables Gibbs; greedy still runs).
91    pub num_gibbs: usize,
92    /// Greedy sweeps per level (early-exits on zero moves).
93    pub num_greedy: usize,
94    /// Per-feature weighting scheme (only meaningful for `Raw` profile source).
95    pub feature_weighting: FeatureWeighting,
96    /// Seed for Gibbs RNG.
97    pub seed: u64,
98    /// Gibbs stagnation threshold (fraction of entities moving per sweep).
99    /// Breaks early when three consecutive sweeps are below this bound.
100    /// `0.0` disables early exit.
101    pub gibbs_stagnation: f64,
102    /// Feature source. Defaults to `Raw`.
103    pub profile_source: ProfileSource,
104    /// Jacobi-style parallel sweeps (default `true`).
105    ///
106    /// When `true`, every entity proposes its move against a frozen pre-sweep
107    /// snapshot of the sufficient stats (rayon `par_iter`); proposals are
108    /// then applied sequentially with the move guard. Per-entity RNG seeds
109    /// are derived from `(sweep_seed, entity_idx)`, so the result is
110    /// deterministic given `seed` and independent of thread count (per
111    /// `rand` release — `SmallRng`'s stream is not guaranteed stable across
112    /// `rand` versions).
113    ///
114    /// When `false`, runs the classic Gauss–Seidel loop: each entity scores
115    /// against the live stats (updated by every accepted move so far in the
116    /// sweep), single-threaded. The statistics are still built on the
117    /// rayon pool; the labels are the same on any number of threads.
118    ///
119    /// Jacobi is biased — within a sweep, entities can't see each other's
120    /// moves — but converges fine for the multilevel DC-Poisson use case
121    /// and is roughly P× faster on large pb-sample counts.
122    pub parallel: bool,
123    /// Pseudo-count `c` added to every block total before its log
124    /// (default `0.5`; values under [`LOG_EPS`] are raised to it). A block
125    /// total is read against what a block of the pooled profile and the
126    /// same mass would show, so a feature a block has not seen costs
127    /// little where none was expected, and an empty block scores as the
128    /// pooled profile. A larger `c` holds block profiles closer to the
129    /// pooled one where counts are few. See [`DcPoissonStats`].
130    pub prior_count: f32,
131    /// Standard deviations of block-estimate noise by which a destination
132    /// has to beat the current block (default `1.5`; `0` asks for any
133    /// positive gain). The noise is larger for smaller blocks, so this is
134    /// what keeps a small block from losing, or taking in, members on the
135    /// strength of its own sampling error. See [`DcPoissonStats`].
136    pub min_gain_z: f32,
137    /// Fraction of `min(starting size, mean block size)` below which no
138    /// block is taken, rounded up (default `0.9`); any positive value also
139    /// keeps the last member of every block. `0` lets blocks shrink and
140    /// empty.
141    ///
142    /// The refinement is asked to make a given partition purer, not to
143    /// choose the number of blocks: by the likelihood alone, the members of
144    /// a mixed block all do better elsewhere, and the block dissolves.
145    ///
146    /// A block at its floor still exchanges members: a move the limits
147    /// refuse is applied together with a move the other way between the
148    /// same two blocks.
149    ///
150    /// Both limits are taken from the sizes a call starts with, so they
151    /// bound one call. Refining the output again starts from the new sizes
152    /// and may take a block a step further: at the default, a tenth of its
153    /// members while it has ten or more, and none below ten unless it is
154    /// larger than the mean. No positive floor empties a block, over any
155    /// number of calls.
156    pub size_floor: f32,
157    /// Multiple of `max(starting size, mean block size)` above which no
158    /// block is taken, rounded up (default `1.25`). `0` lifts the limit.
159    pub size_cap: f32,
160}
161
162impl Default for RefineParams {
163    fn default() -> Self {
164        Self {
165            num_gibbs: 20,
166            num_greedy: 10,
167            feature_weighting: FeatureWeighting::FisherInfoNb,
168            seed: 42,
169            gibbs_stagnation: 0.005,
170            profile_source: ProfileSource::Raw,
171            parallel: true,
172            prior_count: 0.5,
173            min_gain_z: 1.5,
174            size_floor: 0.9,
175            size_cap: 1.25,
176        }
177    }
178}
179
180////////////////////////////////////
181// Sparse row-per-entity profiles //
182////////////////////////////////////
183
184/// Pseudo-count `κ` in the observed and the expected count behind a batch
185/// factor (see [`Profiles::rescale_batches`]).
186pub const BATCH_FACTOR_PSEUDO_COUNT: f64 = 10.0;
187/// Largest batch factor, and the reciprocal of the smallest.
188pub const BATCH_FACTOR_BOUND: f32 = 8.0;
189/// Alternations of the batch-factor fit.
190const BATCH_FACTOR_ROUNDS: usize = 4;
191
192/// Sparse row-per-entity profile, owned by the refinement pass.
193///
194/// Materialized from either a sparse gene-sum format (`from_gene_sums`) or a
195/// projected centroid matrix (`from_projection`). Values are stored sorted
196/// by feature index within each row so downstream accumulators can rely on
197/// the invariant.
198pub struct Profiles {
199    pub rows: Vec<Vec<(u32, f32)>>,
200    pub size_factor: Vec<f32>,
201    pub num_entities: usize,
202    pub num_features: usize,
203}
204
205impl Profiles {
206    pub fn from_gene_sums(gene_sums: &[Vec<(usize, f32)>], num_features: usize) -> Self {
207        let num_entities = gene_sums.len();
208        let (rows, size_factor): (Vec<Vec<(u32, f32)>>, Vec<f32>) = gene_sums
209            .par_iter()
210            .map(|row| {
211                let mut out: Vec<(u32, f32)> = row
212                    .iter()
213                    .filter(|(_, v)| *v > 0.0)
214                    .map(|(g, v)| (*g as u32, *v))
215                    .collect();
216                out.sort_unstable_by_key(|&(g, _)| g);
217                let sf: f32 = out.iter().map(|(_, v)| *v).sum();
218                (out, sf)
219            })
220            .unzip();
221        Self {
222            rows,
223            size_factor,
224            num_entities,
225            num_features,
226        }
227    }
228
229    /// Build profiles from a dense projection.
230    ///
231    /// `basis` is `proj_dim × num_cells`; each entity profile is the sum of
232    /// basis columns over the cells constituting it. Gene weighting is not
233    /// applied — projection dims aren't gene-aligned.
234    ///
235    /// The Poisson score needs nonnegative profiles, so the accumulated
236    /// per-dimension sums must be `>= 0` — a signed basis (e.g. a raw random
237    /// projection) is rejected loudly rather than silently truncated.
238    pub fn from_projection(basis: &DMatrix<f32>, entity_to_cells: &[Vec<usize>]) -> Self {
239        let num_features = basis.nrows();
240        let num_entities = entity_to_cells.len();
241        let (rows, size_factor): (Vec<Vec<(u32, f32)>>, Vec<f32>) = entity_to_cells
242            .par_iter()
243            .map(|cells| {
244                let mut acc = vec![0f32; num_features];
245                for &c in cells {
246                    for d in 0..num_features {
247                        acc[d] += basis[(d, c)];
248                    }
249                }
250                let mut out = Vec::with_capacity(num_features);
251                let mut sf = 0f32;
252                for (d, &v) in acc.iter().enumerate() {
253                    assert!(
254                        v >= 0.0,
255                        "projection profile is negative at dim {d} ({v}); \
256                         DC-Poisson requires a nonnegative basis"
257                    );
258                    if v > 0.0 {
259                        out.push((d as u32, v));
260                        sf += v;
261                    }
262                }
263                (out, sf)
264            })
265            .unzip();
266        Self {
267            rows,
268            size_factor,
269            num_entities,
270            num_features,
271        }
272    }
273
274    /// In-place reweighting by a caller-supplied per-feature weight vector.
275    /// Used by [`Profiles::apply_feature_weighting`] for the NB Fisher-info path.
276    pub fn weight_by_vec(&mut self, w: &[f32]) {
277        assert_eq!(
278            w.len(),
279            self.num_features,
280            "weight vector length must match num_features"
281        );
282        self.rows
283            .par_iter_mut()
284            .zip(self.size_factor.par_iter_mut())
285            .for_each(|(row, sf)| {
286                let mut new_sf = 0f32;
287                for (g, v) in row.iter_mut() {
288                    *v *= w[*g as usize];
289                    new_sf += *v;
290                }
291                *sf = new_sf;
292            });
293    }
294
295    /// Divide each entity's features by its batch's estimated factor, then
296    /// recompute the size factors.
297    ///
298    /// `batch[e]` is the batch of entity `e`. A batch effect multiplies a
299    /// feature's rate in every entity of the batch, so entities of one
300    /// batch fit a block by how much of that batch it holds, whatever its
301    /// content, and a refinement on the raw profiles sorts them by batch.
302    ///
303    /// The factors `δ` are fitted to
304    /// `y_eg ~ Poisson(s_e · λ[c(e), g] · δ[b(e), g])`, `c(e)` the stratum
305    /// of the entity (`strata`; one stratum without it), by alternating
306    /// `λ_cg = Σ_{e ∈ c} y_eg / Σ_b T_bc δ_bg` and
307    /// `δ_bg = (O_bg + κ) / (E_bg + κ)`, with `T_bc` the mass of batch `b`
308    /// in stratum `c`, `O_bg` the batch's count of the feature and
309    /// `E_bg = Σ_c T_bc λ_cg` the count expected of its strata. Three
310    /// things hold a factor near one, where an entity is left as it is:
311    /// - the pseudo-count `κ` ([`BATCH_FACTOR_PSEUDO_COUNT`]): a feature
312    ///   with a few counts in a batch says little about its factor;
313    /// - the replication: a batch of `n` entities gets `δ^((n − 1) / n)`,
314    ///   and a batch of one entity, whose profile cannot be told from a
315    ///   factor, is not touched;
316    /// - a bound of [`BATCH_FACTOR_BOUND`] either way.
317    ///
318    /// # What the strata are for
319    ///
320    /// Without strata the expected count is the pooled profile's, and the
321    /// fit takes every difference between a batch and the pool for a
322    /// technical factor. That holds only when the batches have the same
323    /// composition. A batch that is mostly one cell type has that type's
324    /// features scaled down, and the type no longer looks the same in two
325    /// batches that hold different shares of it. With strata that follow
326    /// the composition (a coarse grouping of the entities), a batch is
327    /// compared with what its own mix of strata would show, and a type
328    /// found in one batch only is compared with itself and left alone.
329    /// Differences in composition within a stratum are still taken for a
330    /// batch factor.
331    pub fn rescale_batches(&mut self, batch: &[usize], strata: Option<&[usize]>) {
332        let n = self.rows.len();
333        assert_eq!(batch.len(), n, "one batch per entity");
334        let nb = batch.iter().copied().max().map_or(0, |b| b + 1);
335        if nb < 2 {
336            return;
337        }
338        let single = vec![0usize; if strata.is_none() { n } else { 0 }];
339        let strata = strata.unwrap_or(&single);
340        assert_eq!(strata.len(), n, "one stratum per entity");
341        let ns = strata.iter().copied().max().map_or(0, |c| c + 1);
342        let m = self.num_features;
343
344        // O_bg, T_bc, and the entities of each batch and stratum.
345        let mut observed = vec![0f64; nb * m];
346        let mut mass = vec![0f64; ns * nb];
347        let mut batch_members = vec![0usize; nb];
348        let mut of_stratum: Vec<Vec<u32>> = vec![Vec::new(); ns];
349        for (e, row) in self.rows.iter().enumerate() {
350            let (b, c) = (batch[e], strata[e]);
351            for &(g, v) in row {
352                observed[b * m + g as usize] += v as f64;
353            }
354            mass[c * nb + b] += self.size_factor[e] as f64;
355            batch_members[b] += 1;
356            of_stratum[c].push(e as u32);
357        }
358
359        // Σ_{e ∈ c} y_eg of each stratum, on the features it shows, and the
360        // batches it holds.
361        let mut dense = vec![0f64; m];
362        let mut touched: Vec<u32> = Vec::new();
363        let mut stratum_sums: Vec<Vec<(u32, f64)>> = Vec::with_capacity(ns);
364        let mut stratum_batches: Vec<Vec<(usize, f64)>> = Vec::with_capacity(ns);
365        for (c, members) in of_stratum.iter().enumerate() {
366            touched.clear();
367            for &e in members {
368                for &(g, v) in &self.rows[e as usize] {
369                    if dense[g as usize] == 0.0 {
370                        touched.push(g);
371                    }
372                    dense[g as usize] += v as f64;
373                }
374            }
375            touched.sort_unstable();
376            touched.dedup();
377            stratum_sums.push(touched.iter().map(|&g| (g, dense[g as usize])).collect());
378            touched.iter().for_each(|&g| dense[g as usize] = 0.0);
379            stratum_batches.push(
380                (0..nb)
381                    .map(|b| (b, mass[c * nb + b]))
382                    .filter(|&(_, t)| t > 0.0)
383                    .collect(),
384            );
385        }
386
387        let mut factor = vec![1f32; nb * m];
388        let mut expected = vec![0f64; nb * m];
389        for _ in 0..BATCH_FACTOR_ROUNDS {
390            expected.iter_mut().for_each(|x| *x = 0.0);
391            for (sums, present) in stratum_sums.iter().zip(&stratum_batches) {
392                for &(g, sum) in sums {
393                    let g = g as usize;
394                    let exposure: f64 = present
395                        .iter()
396                        .map(|&(b, t)| t * factor[b * m + g] as f64)
397                        .sum();
398                    if exposure > 0.0 {
399                        let rate = sum / exposure;
400                        for &(b, t) in present {
401                            expected[b * m + g] += t * rate;
402                        }
403                    }
404                }
405            }
406            for ((f, &o), &x) in factor.iter_mut().zip(&observed).zip(&expected) {
407                *f = ((o + BATCH_FACTOR_PSEUDO_COUNT) / (x + BATCH_FACTOR_PSEUDO_COUNT)) as f32;
408            }
409        }
410        for (b, per_feature) in factor.chunks_mut(m).enumerate() {
411            let members = batch_members[b].max(1) as f32;
412            let trust = (members - 1.0) / members;
413            for f in per_feature {
414                *f = f
415                    .powf(trust)
416                    .clamp(1.0 / BATCH_FACTOR_BOUND, BATCH_FACTOR_BOUND);
417            }
418        }
419
420        self.rows
421            .par_iter_mut()
422            .zip(self.size_factor.par_iter_mut())
423            .zip(batch.par_iter())
424            .for_each(|((row, sf), &b)| {
425                let mut new_sf = 0f32;
426                for (g, v) in row.iter_mut() {
427                    *v /= factor[b * m + *g as usize];
428                    new_sf += *v;
429                }
430                *sf = new_sf;
431            });
432    }
433
434    /// Apply the chosen feature weighting in place.
435    ///
436    /// [`FeatureWeighting::None`] is a no-op; [`FeatureWeighting::FisherInfoNb`]
437    /// fits an NB dispersion trend from the current profiles and reweights each
438    /// feature by `1 / (1 + π_f · s̄ · φ(μ_f))`.
439    pub fn apply_feature_weighting(&mut self, method: FeatureWeighting) {
440        match method {
441            FeatureWeighting::None => {}
442            FeatureWeighting::FisherInfoNb => {
443                let w = self.nb_fisher_weights();
444                self.weight_by_vec(&w);
445            }
446        }
447    }
448
449    /// Compute per-feature Fisher-info weights under an NB trend fit from
450    /// the current profile contents. Returned vector has length `num_features`
451    /// and is suitable for [`Profiles::weight_by_vec`].
452    pub fn nb_fisher_weights(&self) -> Vec<f32> {
453        use crate::alg::nb_dispersion::DispersionTrend;
454        use legume_numeric::matrix::sparse_stat::SparseRunningStatistics;
455        use legume_numeric::matrix::traits::RunningStatOps;
456
457        // Rows are folded in parallel and merged in a fixed order, so the
458        // (non-integer) sums come out the same on any number of threads.
459        // Avoids the single-threaded `for row in &self.rows` over ~10⁴–10⁵
460        // pb-samples.
461        const ROWS_PER_LEAF: usize = 256;
462        let num_features = self.num_features;
463        let (stats, ..) = crate::qc::fold_in_fixed_order(
464            &self.rows,
465            ROWS_PER_LEAF,
466            &|| {
467                (
468                    SparseRunningStatistics::<f32>::new(num_features),
469                    Vec::<usize>::new(),
470                    Vec::<f32>::new(),
471                )
472            },
473            &|(acc, col_rows, col_vals), row| {
474                col_rows.clear();
475                col_vals.clear();
476                for &(g, v) in row {
477                    col_rows.push(g as usize);
478                    col_vals.push(v);
479                }
480                acc.add_sparse_column(col_rows, col_vals);
481                Ok(())
482            },
483            &|(a, ..), (b, ..)| a.merge(&b),
484        )
485        .expect("the row fold cannot fail");
486
487        // `π_g = sum[g] / Σ sum` is derived directly from the stats without
488        // a second sparse traversal. `mean_g` uses the entity-count denominator
489        // that `SparseRunningStatistics` already provides.
490        let trend = DispersionTrend::from_sparse_stats(&stats);
491        let means = stats.mean();
492        let sums = stats.sum();
493        let total_mass: f64 = sums.iter().map(|&s| s as f64).sum();
494        let avg_s = if self.num_entities > 0 {
495            (total_mass / self.num_entities as f64) as f32
496        } else {
497            1.0
498        };
499        let inv_total = if total_mass > 0.0 {
500            1.0 / total_mass as f32
501        } else {
502            0.0
503        };
504        (0..self.num_features)
505            .map(|g| trend.fisher_weight(sums[g] * inv_total, avg_s, means[g]))
506            .collect()
507    }
508}
509
510///////////////////////////////////////////////
511// Null moments of the log of a block total //
512///////////////////////////////////////////////
513
514/// Grid step of [`NullLog`] in `ln μ`.
515const NULL_STEP: f64 = 1.0 / 48.0;
516/// `ln μ` below which a total is zero for every purpose here.
517const NULL_LN_MIN: f64 = -20.0;
518/// Mean from which the moments are taken from the expansion around `μ + c`.
519const NULL_MU_MAX: f64 = 64.0;
520
521/// Mean and variance of `ln(G + c)` for `G ~ Poisson(μ)`, tabulated on a
522/// grid of `ln μ` and read by linear interpolation.
523///
524/// The mean is what the log of a block total comes to when the block has
525/// the pooled profile; subtracting it leaves a log-ratio that is zero on
526/// average for such a block at every `μ`, down to totals of zero and one,
527/// where an expansion around `μ` no longer describes `ln(G + c)`.
528#[derive(Clone)]
529struct NullLog {
530    c: f64,
531    /// `ln c`, rounded as the cached block logs are.
532    ln_c: f64,
533    /// `E ln(G + c) − ln c` at each grid point.
534    mean: Vec<f64>,
535    /// `Var ln(G + c)` at each grid point.
536    var: Vec<f64>,
537}
538
539impl NullLog {
540    fn new(c: f64) -> Self {
541        let points = ((NULL_MU_MAX.ln() - NULL_LN_MIN) / NULL_STEP).ceil() as usize + 2;
542        let (mean, var) = (0..points)
543            .map(|i| Self::poisson_moments((NULL_LN_MIN + i as f64 * NULL_STEP).exp(), c))
544            .unzip();
545        Self {
546            c,
547            ln_c: fast_ln(c),
548            mean,
549            var,
550        }
551    }
552
553    /// `(E ln(1 + G / c), Var ln(G + c))` for `G ~ Poisson(mu)`, by summing
554    /// over the counts that carry the mass.
555    fn poisson_moments(mu: f64, c: f64) -> (f64, f64) {
556        let last = (mu + 12.0 * mu.sqrt() + 25.0) as usize;
557        let mut p = (-mu).exp();
558        let (mut m1, mut m2) = (0f64, 0f64);
559        for n in 0..=last {
560            let l = (n as f64 / c).ln_1p();
561            m1 += p * l;
562            m2 += p * l * l;
563            p *= mu / (n as f64 + 1.0);
564        }
565        (m1, (m2 - m1 * m1).max(0.0))
566    }
567
568    /// `(E ln(G + c), Var ln(G + c))` at mean `mu`, with `ln_mu = ln(mu)`.
569    #[inline]
570    fn at(&self, ln_mu: f64, mu: f64) -> (f64, f64) {
571        if mu >= NULL_MU_MAX {
572            // Moments of ln(m + X) for a centred Poisson X around m = μ + c:
573            // central moments μ, μ, 3μ² + μ.
574            let m = mu + self.c;
575            let r = 1.0 / m;
576            let v = mu * r * r;
577            let mean =
578                m.ln() - 0.5 * v + v * r / 3.0 - (3.0 * mu * mu + mu) * (r * r) * (r * r) / 4.0;
579            (mean, v)
580        } else if ln_mu > NULL_LN_MIN {
581            let pos = (ln_mu - NULL_LN_MIN) / NULL_STEP;
582            let i = pos as usize;
583            let f = pos - i as f64;
584            (
585                self.ln_c + self.mean[i] + f * (self.mean[i + 1] - self.mean[i]),
586                self.var[i] + f * (self.var[i + 1] - self.var[i]),
587            )
588        } else {
589            (self.ln_c, 0.0)
590        }
591    }
592
593    /// The moments for a block of mass `size` at each class rate.
594    fn row(&self, size: f64, classes: &RateClasses, mean: &mut [f64], var: &mut [f64]) {
595        // `ln 0 = −∞`: an empty block has rate zero everywhere.
596        let ln_size = size.ln();
597        for (((m, v), &rate), &ln_rate) in mean
598            .iter_mut()
599            .zip(var.iter_mut())
600            .zip(&classes.rate)
601            .zip(&classes.ln_rate)
602        {
603            (*m, *v) = self.at(ln_size + ln_rate, size * rate);
604        }
605    }
606}
607
608/// Width of a rate class in `ln π`: four classes per octave.
609const CLASS_STEP: f64 = std::f64::consts::LN_2 / 4.0;
610/// Most classes a refinement keeps (44 nats of pooled rate).
611const MAX_CLASSES: usize = 256;
612
613/// Pooled rates at which the null moments of every block are kept.
614///
615/// The null mean of `ln(G_kg + c)` depends on the block and the feature
616/// through `S_k · π_g` only. Features are therefore binned by `ln π_g` on
617/// an even grid, each feature split between its two neighbouring grid
618/// points, so that a block keeps one value per grid point and an entity one
619/// weight per grid point. The last class is the rate zero: features that no
620/// other entity shows.
621#[derive(Clone)]
622struct RateClasses {
623    /// `ln π` of class 0, the largest rate.
624    top: f64,
625    /// Rate of each class; zero for the last.
626    rate: Vec<f64>,
627    /// `ln` of each rate; `−∞` for the last.
628    ln_rate: Vec<f64>,
629}
630
631impl RateClasses {
632    /// Classes covering pooled rates from `min_entry / total` to
633    /// `max_pooled / rest_min`, with an octave to spare on either side.
634    ///
635    /// An entity's rates are taken without it, `(P_g − y_eg) / (T − s_e)`,
636    /// so the largest is bounded by `max_pooled` over the smallest
637    /// remainder `rest_min = T − max_e s_e`, which an entity holding much
638    /// of the mass pushes well above `max_pooled / T`.
639    fn new(max_pooled: f64, min_entry: f64, total: f64, rest_min: f64) -> Self {
640        let ln2 = std::f64::consts::LN_2;
641        let (top, len) = if total > 0.0 && max_pooled > 0.0 && min_entry.is_finite() {
642            // A lone entity with mass has no rate but zero; any positive
643            // remainder will do then.
644            let rest_min = if rest_min > 0.0 { rest_min } else { total };
645            let top = (max_pooled / rest_min.min(total)).ln() + ln2;
646            let bottom = (min_entry / total).ln() - ln2;
647            let span = ((top - bottom) / CLASS_STEP).ceil() as usize;
648            (top, (span + 2).clamp(3, MAX_CLASSES))
649        } else {
650            (0.0, 2)
651        };
652        let mut ln_rate: Vec<f64> = (0..len - 1).map(|j| top - j as f64 * CLASS_STEP).collect();
653        let mut rate: Vec<f64> = ln_rate.iter().map(|x| x.exp()).collect();
654        ln_rate.push(f64::NEG_INFINITY);
655        rate.push(0.0);
656        Self { top, rate, ln_rate }
657    }
658
659    fn len(&self) -> usize {
660        self.rate.len()
661    }
662
663    /// Add `weight` and `weight²` of a feature of pooled rate
664    /// `pooled / total` to the class sums `mass` and `sq`.
665    #[inline]
666    fn add(&self, pooled: f64, total: f64, weight: f64, mass: &mut [f64], sq: &mut [f64]) {
667        let zero = self.len() - 1;
668        if !(pooled > 0.0 && total > 0.0) {
669            mass[zero] += weight;
670            sq[zero] += weight * weight;
671            return;
672        }
673        let pos = ((self.top - fast_ln(pooled / total)) / CLASS_STEP).clamp(0.0, (zero - 1) as f64);
674        let lo = pos as usize;
675        let f = pos - lo as f64;
676        mass[lo] += weight * (1.0 - f);
677        sq[lo] += weight * weight * (1.0 - f);
678        if f > 0.0 {
679            mass[lo + 1] += weight * f;
680            sq[lo + 1] += weight * weight * f;
681        }
682    }
683}
684
685/// `Σ_j w_j · t_j`, in class order.
686#[inline(always)]
687fn class_dot(w: &[f32], t: &[f64]) -> f64 {
688    w.iter().zip(t).map(|(&w, &t)| w as f64 * t).sum()
689}
690
691///////////////////////////////////
692// Poisson sufficient statistics //
693///////////////////////////////////
694
695/// Sufficient statistics with cached log quantities for O(K · nnz(row)) scoring.
696///
697/// `gene_sum[g·K + k]` = Σ_{e : z_e = k} y_eg; `size_sum[k]` = Σ_{e : z_e = k} s_e.
698/// The per-feature tables are laid out **feature-major** (`g·K + k`): scoring
699/// one entity walks its nonzero features once and, for each, reads the `K`
700/// group values side by side, instead of striding across a `K × M` table
701/// once per candidate group. Use [`DcPoissonStats::idx`] to address them.
702/// Log caches score every non-current block with no log; the current block's
703/// leave-one-out score takes one `fast_ln` per nonzero feature, and
704/// `delta_move` refreshes the logs of the columns a move touches.
705///
706/// # The score
707///
708/// An entity is scored against block `k` by the log-likelihood ratio of the
709/// block's profile to the pooled profile `π`,
710/// `Σ_g y_eg · ln(θ_kg / π_g)`, with each log-ratio estimated by
711///
712/// `  u_kg = ln(G_kg + c) − E₀ ln(G + c),   G ~ Poisson(S_k · π_g)`.
713///
714/// `c = prior_count` is a pseudo-count in every block total. The second
715/// term is the mean of the first for a block that has the pooled profile
716/// (`NullLog`), so `u_kg` is zero on average for such a block whatever its
717/// mass, and blocks of the pooled profile score alike whatever their size.
718/// A plain `ln((G + a) / (S + α))` does not have that property: its mean
719/// falls with the block's mass (the log of a noisy total is biased low),
720/// and on a feature the block has not seen it reads a rate proportional to
721/// `1 / S_k`. With some 20,000 heavy-tailed features most totals of a
722/// small block are zero or one, and either effect outweighs the evidence.
723///
724/// `ln((G + c) / (S_k π_g + c))` is the posterior-mean rate ratio under a
725/// `Gamma(c, c)` prior on `θ_kg / π_g`: a feature expected less than `c`
726/// times in the block stays near the pooled rate, one expected many times
727/// reads `G / (S_k π_g)`. Blocks that share a profile other than the pooled
728/// one are therefore shrunk toward the pooled profile by different amounts
729/// when their masses differ, and an entity of that profile fits the larger
730/// one better. A smaller `c` shrinks less and is noisier.
731///
732/// `π_g` is taken without the entity scored (`(P_g − y_eg) / (T − s_e)`,
733/// `P` the pooled totals): a rare feature's pooled rate is otherwise made
734/// of the entity's own counts. A feature nothing else shows carries no
735/// evidence for any block. The block scored is not taken out of `π`: a
736/// block that makes up most of the pooled mass is in part its own null,
737/// and is favoured over a small block of its profile.
738///
739/// The null mean depends on `(k, g)` through `S_k π_g` only, so it is kept
740/// per block on a grid of `ln π` (`RateClasses`), and an entity carries
741/// its counts summed onto the same grid: the score costs one cached log per
742/// (nonzero, block) and one short dot product per block.
743///
744/// What remains under the null is zero-mean noise of variance
745/// `Σ_g y_eg² · Var₀ ln(G_kg + c)`, read off the same grid. A destination is
746/// charged `min_gain_z` standard deviations of the difference between its
747/// score and the current block's,
748/// `z · sqrt(Σ_g y_eg² (Var₀[cur, g] + Var₀[k, g]))`: a move has to beat
749/// what the block estimates alone would produce between two blocks of one
750/// profile.
751///
752/// The moments are those of Poisson totals. Totals of profiles weighted by
753/// [`FeatureWeighting::FisherInfoNb`] are not counts, but under the negative
754/// binomial model the weights come from (`w = 1 / (1 + μ φ)`, the ratio of
755/// a count's mean to its variance) a weighted total again has a variance
756/// equal to its mean.
757///
758/// Log caches are `f32` — values live in roughly `[-21, +25]` and scoring
759/// is noise-dominated, so f32's ~7-digit precision is ample. Halves the
760/// largest allocation versus f64.
761#[derive(Clone)]
762pub struct DcPoissonStats {
763    pub k: usize,
764    pub num_features: usize,
765    pub membership: Vec<usize>,
766    /// Feature-major `M × K`: entry `g·K + k`.
767    pub gene_sum: Vec<f64>,
768    pub size_sum: Vec<f64>,
769    /// Feature-major `M × K`: entry `g·K + k`. Holds `ln(G_kg + c)`.
770    pub log_gene: Vec<f32>,
771    /// `−ln(S_k + M · LOG_EPS)`. Not part of the score, which compares a
772    /// block with the pooled profile feature by feature.
773    pub log_size_offset: Vec<f32>,
774    /// Members per block.
775    pub count: Vec<usize>,
776    /// Standard deviations of estimate noise a move has to clear.
777    pub min_gain_z: f64,
778    /// Pseudo-count `c` in every block total.
779    prior: f64,
780    null: NullLog,
781    classes: RateClasses,
782    /// Entity-major `N × J`: counts of each entity summed per rate class.
783    class_mass: Vec<f32>,
784    /// Entity-major `N × J`: squared counts per rate class.
785    class_sq: Vec<f32>,
786    /// Block-major `K × J`: null mean of `ln(G + c)` at each class rate.
787    class_null: Vec<f64>,
788    /// Block-major `K × J`: null variance of `ln(G + c)` at each class rate.
789    class_var: Vec<f64>,
790}
791
792impl DcPoissonStats {
793    /// Index of (group `k`, feature `g`) in `gene_sum` / `log_gene`.
794    #[inline(always)]
795    pub fn idx(&self, k: usize, g: usize) -> usize {
796        g * self.k + k
797    }
798
799    /// Statistics under the default [`RefineParams`] scoring.
800    pub fn from_profiles(profiles: &Profiles, k: usize, membership: &[usize]) -> Self {
801        Self::from_profiles_with(profiles, k, membership, &RefineParams::default())
802    }
803
804    /// Statistics scored with `params.prior_count` and `params.min_gain_z`.
805    pub fn from_profiles_with(
806        profiles: &Profiles,
807        k: usize,
808        membership: &[usize],
809        params: &RefineParams,
810    ) -> Self {
811        let m = profiles.num_features;
812        let mut gene_sum = vec![0f64; k * m];
813        let mut size_sum = vec![0f64; k];
814        let mut count = vec![0usize; k];
815        let mut pooled = vec![0f64; m];
816        let mut min_entry = f64::INFINITY;
817        for (e, row) in profiles.rows.iter().enumerate() {
818            let z = membership[e];
819            assert!(z < k, "membership[{}]={} out of range 0..{}", e, z, k);
820            for &(g, v) in row {
821                gene_sum[g as usize * k + z] += v as f64;
822                pooled[g as usize] += v as f64;
823                if v > 0.0 {
824                    min_entry = min_entry.min(v as f64);
825                }
826            }
827            size_sum[z] += profiles.size_factor[e] as f64;
828            count[z] += 1;
829        }
830        let total: f64 = pooled.iter().sum();
831        let max_pooled = pooled.iter().copied().fold(0f64, f64::max);
832        let max_sf = profiles
833            .size_factor
834            .iter()
835            .fold(0f64, |a, &s| a.max(s as f64));
836        let classes = RateClasses::new(max_pooled, min_entry, total, total - max_sf);
837        let jj = classes.len();
838
839        // Each entity's counts on the rate grid, the pooled rate of a
840        // feature taken without the entity.
841        let n = profiles.rows.len();
842        let mut class_mass = vec![0f32; n * jj];
843        let mut class_sq = vec![0f32; n * jj];
844        class_mass
845            .par_chunks_mut(jj)
846            .zip(class_sq.par_chunks_mut(jj))
847            .zip(profiles.rows.par_iter())
848            .zip(profiles.size_factor.par_iter())
849            .for_each_init(
850                || (vec![0f64; jj], vec![0f64; jj]),
851                |(mass, sq), (((mass_out, sq_out), row), &sf)| {
852                    mass.iter_mut().for_each(|x| *x = 0.0);
853                    sq.iter_mut().for_each(|x| *x = 0.0);
854                    let rest = total - sf as f64;
855                    for &(g, v) in row {
856                        if v > 0.0 {
857                            let p = pooled[g as usize];
858                            let others = p - v as f64;
859                            // a rounding residue of the entity's own count is no one else's
860                            let others = if others > 1e-9 * p { others } else { 0.0 };
861                            classes.add(others, rest, v as f64, mass, sq);
862                        }
863                    }
864                    for (o, &x) in mass_out.iter_mut().zip(mass.iter()) {
865                        *o = x as f32;
866                    }
867                    for (o, &x) in sq_out.iter_mut().zip(sq.iter()) {
868                        *o = x as f32;
869                    }
870                },
871            );
872
873        let prior = (params.prior_count as f64).max(LOG_EPS);
874        let mut stats = Self {
875            k,
876            num_features: m,
877            membership: membership.to_vec(),
878            gene_sum,
879            size_sum,
880            log_gene: vec![0f32; k * m],
881            log_size_offset: vec![0f32; k],
882            count,
883            min_gain_z: params.min_gain_z.max(0.0) as f64,
884            prior,
885            null: NullLog::new(prior),
886            classes,
887            class_mass,
888            class_sq,
889            class_null: vec![0f64; k * jj],
890            class_var: vec![0f64; k * jj],
891        };
892        stats.refresh_caches();
893        stats
894    }
895
896    /// Refresh the per-block caches of block `k`.
897    #[inline]
898    fn refresh_block(&mut self, k: usize) {
899        let jj = self.classes.len();
900        self.log_size_offset[k] =
901            -fast_ln(self.size_sum[k] + self.num_features as f64 * LOG_EPS) as f32;
902        self.null.row(
903            self.size_sum[k],
904            &self.classes,
905            &mut self.class_null[k * jj..(k + 1) * jj],
906            &mut self.class_var[k * jj..(k + 1) * jj],
907        );
908    }
909
910    /// Rebuild every cache from the running sums.
911    fn refresh_caches(&mut self) {
912        let c = self.prior;
913        self.log_gene
914            .par_iter_mut()
915            .zip(self.gene_sum.par_iter())
916            .with_min_len(1 << 14)
917            .for_each(|(l, &s)| *l = fast_ln(s + c) as f32);
918        for b in 0..self.k {
919            self.refresh_block(b);
920        }
921    }
922
923    /// Apply an entity's reassignment and refresh only the affected log entries.
924    pub fn delta_move(&mut self, e: usize, k_from: usize, k_to: usize, profiles: &Profiles) {
925        if k_from == k_to {
926            return;
927        }
928        let kk = self.k;
929        let c = self.prior;
930
931        // Subtractions clamp at zero: the running sums accumulate rounding
932        // residues, and a block drained back to (true) zero can otherwise be
933        // left with a negative residue larger than its pseudo-count —
934        // feeding `ln(negative) = NaN` into the log caches.
935        for &(g, v) in &profiles.rows[e] {
936            let base = g as usize * kk;
937            let (i_from, i_to) = (base + k_from, base + k_to);
938            self.gene_sum[i_from] = (self.gene_sum[i_from] - v as f64).max(0.0);
939            self.gene_sum[i_to] += v as f64;
940            self.log_gene[i_from] = fast_ln(self.gene_sum[i_from] + c) as f32;
941            self.log_gene[i_to] = fast_ln(self.gene_sum[i_to] + c) as f32;
942        }
943        let sf = profiles.size_factor[e] as f64;
944        self.size_sum[k_from] = (self.size_sum[k_from] - sf).max(0.0);
945        self.size_sum[k_to] += sf;
946        self.count[k_from] = self.count[k_from].saturating_sub(1);
947        self.count[k_to] += 1;
948        self.refresh_block(k_from);
949        self.refresh_block(k_to);
950
951        self.membership[e] = k_to;
952    }
953
954    /// Full recompute from current `membership` (slow path, used for tests).
955    #[cfg(test)]
956    fn recompute(&mut self, profiles: &Profiles) {
957        let k = self.k;
958        self.gene_sum.iter_mut().for_each(|x| *x = 0.0);
959        self.size_sum.iter_mut().for_each(|x| *x = 0.0);
960        self.count.iter_mut().for_each(|x| *x = 0);
961        for (e, row) in profiles.rows.iter().enumerate() {
962            let z = self.membership[e];
963            for &(g, v) in row {
964                self.gene_sum[g as usize * k + z] += v as f64;
965            }
966            self.size_sum[z] += profiles.size_factor[e] as f64;
967            self.count[z] += 1;
968        }
969        self.refresh_caches();
970    }
971
972    /// The rate-class weights of entity `e`: its counts and squared counts.
973    #[inline(always)]
974    fn entity_classes(&self, e: usize) -> (&[f32], &[f32]) {
975        let jj = self.classes.len();
976        (
977            &self.class_mass[e * jj..(e + 1) * jj],
978            &self.class_sq[e * jj..(e + 1) * jj],
979        )
980    }
981
982    /// The null moments of block `k` at each class rate.
983    #[inline(always)]
984    fn block_classes(&self, k: usize) -> (&[f64], &[f64]) {
985        let jj = self.classes.len();
986        (
987            &self.class_null[k * jj..(k + 1) * jj],
988            &self.class_var[k * jj..(k + 1) * jj],
989        )
990    }
991}
992
993/////////////////////
994// Scoring kernels //
995/////////////////////
996
997/// Score entity `e` for one destination `k`, as documented on
998/// [`compute_log_probs_restricted`], which scores all allowed blocks in one
999/// pass with the same per-block summation order (so the two agree bit for
1000/// bit). Test reference.
1001#[cfg(test)]
1002fn score_move(e: usize, k: usize, stats: &DcPoissonStats, profiles: &Profiles) -> f64 {
1003    let jj = stats.classes.len();
1004    let sf = profiles.size_factor[e] as f64;
1005    let row = &profiles.rows[e];
1006    let cur = stats.membership[e];
1007    let (mass, sq) = stats.entity_classes(e);
1008    let (mut loo_mean, mut loo_var) = ([0f64; MAX_CLASSES], [0f64; MAX_CLASSES]);
1009    let loo_size = (stats.size_sum[cur] - sf).max(0.0);
1010    stats.null.row(
1011        loo_size,
1012        &stats.classes,
1013        &mut loo_mean[..jj],
1014        &mut loo_var[..jj],
1015    );
1016    if cur == k {
1017        let mut acc = -class_dot(mass, &loo_mean[..jj]);
1018        for &(g, v) in row {
1019            let vg = v as f64;
1020            let loo = (stats.gene_sum[stats.idx(k, g as usize)] - vg).max(0.0);
1021            acc += vg * fast_ln(loo + stats.prior);
1022        }
1023        acc
1024    } else {
1025        let (null, var) = stats.block_classes(k);
1026        let mut acc = -class_dot(mass, null);
1027        for &(g, v) in row {
1028            acc += v as f64 * stats.log_gene[stats.idx(k, g as usize)] as f64;
1029        }
1030        if stats.min_gain_z > 0.0 {
1031            let noise = class_dot(sq, &loo_var[..jj]) + class_dot(sq, var);
1032            acc -= stats.min_gain_z * noise.sqrt();
1033        }
1034        acc
1035    }
1036}
1037
1038/// Log-probability of placing entity `e` into each of the `allowed` blocks.
1039///
1040/// `  s(e, k) = Σ_{g : y_eg > 0} y_eg · (ln(G⁻ᵉ[k, g] + c) − E₀ ln(G + c)),   G ~ Poisson(S⁻ᵉ[k] · π⁻ᵉ[g])`
1041///
1042/// the log-likelihood ratio of block `k`'s profile to the pooled profile
1043/// `π`, each log-ratio centred on its mean for a block of the pooled
1044/// profile (see [`DcPoissonStats`]). `⁻ᵉ` means entity `e`'s own
1045/// contribution is excluded: candidate blocks never contain `e` and read
1046/// the log caches, while `e`'s *current* block subtracts `e`'s row before
1047/// taking logs (leave-one-out) — otherwise "stay put" earns a
1048/// self-inclusion bonus that anchors large entities.
1049///
1050/// Every block other than the current one is then charged
1051/// `min_gain_z` standard deviations of the estimate noise of the
1052/// difference to the current block's score, so that comparing a slot with
1053/// the current block's — by argmax or by sampling — already asks for a gain
1054/// beyond that noise.
1055///
1056/// All blocks are scored in one pass over `e`'s nonzero features: per
1057/// feature, the `K` cached values sit side by side (feature-major layout),
1058/// so the pass touches one short contiguous run per feature. Each block's
1059/// sum is still accumulated in feature order, the same order as scoring the
1060/// blocks one at a time, so the scores match that bit for bit. Partial
1061/// candidate lists accumulate by *position* in `allowed`, so a block listed
1062/// twice gets the same, correct score in both places.
1063///
1064/// Only the `allowed` slots of `log_probs` are written, unless `allowed`
1065/// is as long as `K`, in which case the first `K` slots are written.
1066/// The rest keep whatever stale values a previous call left (the caller
1067/// reuses one buffer across sweeps). The sweeps of this module pick a block
1068/// from the `allowed` slots and the current block's slot only, as do the
1069/// public pickers [`sample_categorical_log_restricted`],
1070/// [`argmax_log_restricted`] and [`argmax_log_restricted_from`].
1071pub fn compute_log_probs_restricted(
1072    e: usize,
1073    stats: &DcPoissonStats,
1074    profiles: &Profiles,
1075    allowed: &[usize],
1076    log_probs: &mut [f64],
1077) {
1078    let kk = stats.k;
1079    let jj = stats.classes.len();
1080    let sf = profiles.size_factor[e] as f64;
1081    let row = &profiles.rows[e];
1082    let cur = stats.membership[e];
1083    let c = stats.prior;
1084    let (mass, sq) = stats.entity_classes(e);
1085    // Every block 0..K is scored straight into `log_probs`, so repeats in
1086    // `allowed` (which then misses some block) are harmless.
1087    let dense = allowed.len() == kk;
1088    let score_cur = dense || allowed.contains(&cur);
1089
1090    // Null moments of the current block without the entity.
1091    let z = stats.min_gain_z;
1092    let (mut loo_mean, mut loo_var) = ([0f64; MAX_CLASSES], [0f64; MAX_CLASSES]);
1093    if score_cur || z > 0.0 {
1094        let loo_size = (stats.size_sum[cur] - sf).max(0.0);
1095        stats.null.row(
1096            loo_size,
1097            &stats.classes,
1098            &mut loo_mean[..jj],
1099            &mut loo_var[..jj],
1100        );
1101    }
1102
1103    // Leave-one-out score of the current block.
1104    let mut cur_acc = if score_cur {
1105        -class_dot(mass, &loo_mean[..jj])
1106    } else {
1107        0.0
1108    };
1109    let mut add_cur = |base: usize, vg: f64| {
1110        if score_cur {
1111            let loo = (stats.gene_sum[base + cur] - vg).max(0.0);
1112            cur_acc += vg * fast_ln(loo + c);
1113        }
1114    };
1115
1116    // Start of a block's sum, and the estimate noise of a move into it.
1117    let start = |k: usize| -class_dot(mass, stats.block_classes(k).0);
1118    let noise_cur = if z > 0.0 {
1119        class_dot(sq, &loo_var[..jj])
1120    } else {
1121        0.0
1122    };
1123    let charge = |k: usize| z * (noise_cur + class_dot(sq, stats.block_classes(k).1)).sqrt();
1124
1125    // Cached (non-leave-one-out) scores for every allowed block, including
1126    // the current one, which is overwritten at the end.
1127    if dense {
1128        for (k, slot) in log_probs[..kk].iter_mut().enumerate() {
1129            *slot = start(k);
1130        }
1131        for &(g, v) in row {
1132            let vg = v as f64;
1133            let base = g as usize * kk;
1134            let logs = &stats.log_gene[base..base + kk];
1135            for (slot, &l) in log_probs[..kk].iter_mut().zip(logs) {
1136                *slot += vg * l as f64;
1137            }
1138            add_cur(base, vg);
1139        }
1140        if z > 0.0 {
1141            for (k, slot) in log_probs[..kk].iter_mut().enumerate() {
1142                *slot -= charge(k);
1143            }
1144        }
1145    } else {
1146        thread_local! {
1147            static SCRATCH: std::cell::RefCell<Vec<f64>> = const { std::cell::RefCell::new(Vec::new()) };
1148        }
1149        SCRATCH.with(|scratch| {
1150            let mut scratch = scratch.borrow_mut();
1151            scratch.clear();
1152            scratch.extend(allowed.iter().map(|&k| start(k)));
1153            for &(g, v) in row {
1154                let vg = v as f64;
1155                let base = g as usize * kk;
1156                let logs = &stats.log_gene[base..base + kk];
1157                for (acc, &k) in scratch.iter_mut().zip(allowed) {
1158                    *acc += vg * logs[k] as f64;
1159                }
1160                add_cur(base, vg);
1161            }
1162            for (&acc, &k) in scratch.iter().zip(allowed) {
1163                log_probs[k] = if z > 0.0 { acc - charge(k) } else { acc };
1164            }
1165        });
1166    }
1167    if score_cur {
1168        log_probs[cur] = cur_acc;
1169    }
1170}
1171
1172/// Unrestricted variant used only from tests.
1173#[cfg(test)]
1174fn compute_log_probs(e: usize, stats: &DcPoissonStats, profiles: &Profiles, log_probs: &mut [f64]) {
1175    for (k, slot) in log_probs.iter_mut().enumerate().take(stats.k) {
1176        *slot = score_move(e, k, stats, profiles);
1177    }
1178}
1179
1180/// Gumbel-max categorical sampling over the `allowed` slots of `log_probs`,
1181/// skipping non-finite entries. Falls back to `current` (the entity's
1182/// present label — always a legal "move") when no allowed slot is finite,
1183/// so a degenerate score vector can never produce an out-of-range label.
1184pub fn sample_categorical_log_restricted(
1185    log_probs: &[f64],
1186    allowed: &[usize],
1187    current: usize,
1188    rng: &mut SmallRng,
1189) -> usize {
1190    let mut best_key = f64::NEG_INFINITY;
1191    let mut best_idx = current;
1192    for &k in allowed {
1193        let lp = log_probs[k];
1194        if lp.is_finite() {
1195            let key = gumbel_key(lp, rng);
1196            if key > best_key {
1197                best_key = key;
1198                best_idx = k;
1199            }
1200        }
1201    }
1202    best_idx
1203}
1204
1205/// `lp` plus a standard Gumbel draw: the key of a Gumbel-max draw.
1206#[inline]
1207fn gumbel_key(lp: f64, rng: &mut SmallRng) -> f64 {
1208    let u: f64 = rng.random_range(1e-12..1.0_f64);
1209    lp - (-u.ln()).ln()
1210}
1211
1212/// Argmax over `allowed` indices.
1213pub fn argmax_log_restricted(log_probs: &[f64], allowed: &[usize]) -> usize {
1214    let mut best = allowed[0];
1215    let mut best_val = log_probs[best];
1216    for &k in &allowed[1..] {
1217        if log_probs[k] > best_val {
1218            best = k;
1219            best_val = log_probs[k];
1220        }
1221    }
1222    best
1223}
1224
1225/// Argmax over `allowed` indices that leaves `current` only for a strictly
1226/// better block: a tie with the current label (an entity with no evidence
1227/// scores every block alike) is not a move. With `current` outside
1228/// `allowed`, ties go to the first allowed index.
1229pub fn argmax_log_restricted_from(log_probs: &[f64], allowed: &[usize], current: usize) -> usize {
1230    let mut best = if allowed.contains(&current) {
1231        current
1232    } else {
1233        allowed[0]
1234    };
1235    let mut best_val = log_probs[best];
1236    for &k in allowed {
1237        if log_probs[k] > best_val {
1238            best = k;
1239            best_val = log_probs[k];
1240        }
1241    }
1242    best
1243}
1244
1245/// Score of the entity's `current` block, the bar a destination has to
1246/// clear. A candidate list without the current block (which a
1247/// [`CandidateProposer`] must not return) leaves that slot stale, so the
1248/// bar is then absent and any finite destination clears it.
1249#[inline]
1250fn staying_score(log_probs: &[f64], allowed: &[usize], current: usize) -> f64 {
1251    if allowed.contains(&current) {
1252        log_probs[current]
1253    } else {
1254        f64::NEG_INFINITY
1255    }
1256}
1257
1258/// Gumbel-max draw among the entity's `current` block and the `allowed`
1259/// blocks that score strictly above it; non-finite scores are skipped.
1260///
1261/// A block that does not beat the current one is never drawn, so an entity
1262/// with no evidence for a move stays where it is, and a sweep of draws
1263/// cannot shuffle entities between blocks that score alike.
1264fn sample_improving_log(
1265    log_probs: &[f64],
1266    allowed: &[usize],
1267    current: usize,
1268    rng: &mut SmallRng,
1269) -> usize {
1270    let cur_val = staying_score(log_probs, allowed, current);
1271    let mut best_idx = current;
1272    let mut best_key = if cur_val.is_finite() {
1273        gumbel_key(cur_val, rng)
1274    } else {
1275        f64::NEG_INFINITY
1276    };
1277    for &k in allowed {
1278        let lp = log_probs[k];
1279        if k != current && lp.is_finite() && lp > cur_val {
1280            let key = gumbel_key(lp, rng);
1281            if key > best_key {
1282                best_key = key;
1283                best_idx = k;
1284            }
1285        }
1286    }
1287    best_idx
1288}
1289
1290/// Best-scoring block of `allowed`, keeping `current` unless another block
1291/// scores strictly higher: a tie, or a score that does not compare, is no
1292/// evidence for a move.
1293fn argmax_log_staying(log_probs: &[f64], allowed: &[usize], current: usize) -> usize {
1294    let mut best = current;
1295    let mut best_val = staying_score(log_probs, allowed, current);
1296    for &k in allowed {
1297        if k != current && log_probs[k] > best_val {
1298            best = k;
1299            best_val = log_probs[k];
1300        }
1301    }
1302    best
1303}
1304
1305////////////////////////////////////
1306// Hierarchy / relabeling helpers //
1307////////////////////////////////////
1308
1309/// Compact a label vector to dense `0..K` ids, preserving relative order of
1310/// first appearance. Returns the new labels and the new `K`. Generic over
1311/// the key type so it serves both plain group ids (`usize`) and composite
1312/// keys (e.g. `(child, parent)` pairs in `project_to_refinement`).
1313pub fn compact_labels<K>(labels: &[K]) -> (Vec<usize>, usize)
1314where
1315    K: Copy + Eq + std::hash::Hash,
1316{
1317    let mut map: HashMap<K, usize> = HashMap::default();
1318    let mut next = 0usize;
1319    let mut out = Vec::with_capacity(labels.len());
1320    for &g in labels {
1321        let new = *map.entry(g).or_insert_with(|| {
1322            let id = next;
1323            next += 1;
1324            id
1325        });
1326        out.push(new);
1327    }
1328    (out, next)
1329}
1330
1331/// For each entity, siblings at `level` = groups at `level` sharing the
1332/// same parent group at `level + 1`.
1333///
1334/// `refined` follows the finest → coarsest convention: `refined[0]` is the
1335/// finest level, `refined[refined.len() - 1]` is the coarsest. At the
1336/// coarsest level (index `num_levels - 1`), the virtual root groups every
1337/// entity together → siblings = all groups.
1338pub fn compute_sibling_sets(
1339    refined: &[Vec<usize>],
1340    level: usize,
1341    num_groups_at_level: usize,
1342) -> Vec<Vec<usize>> {
1343    let num_levels = refined.len();
1344    let num_entities = refined[level].len();
1345
1346    if level + 1 >= num_levels {
1347        let all_groups: Vec<usize> = (0..num_groups_at_level).collect();
1348        return vec![all_groups; num_entities];
1349    }
1350
1351    let mut parent_to_children: HashMap<usize, Vec<usize>> = HashMap::default();
1352    for (child, parent) in refined[level].iter().zip(refined[level + 1].iter()) {
1353        let entry = parent_to_children.entry(*parent).or_default();
1354        if !entry.contains(child) {
1355            entry.push(*child);
1356        }
1357    }
1358    for v in parent_to_children.values_mut() {
1359        v.sort_unstable();
1360    }
1361
1362    (0..num_entities)
1363        .map(|e| {
1364            let parent = refined[level + 1][e];
1365            parent_to_children.get(&parent).cloned().unwrap_or_default()
1366        })
1367        .collect()
1368}
1369
1370////////////////////////////////////////////
1371// Candidate proposer + move guard traits //
1372////////////////////////////////////////////
1373
1374/// Propose candidate group labels each entity may move into on one sweep.
1375///
1376/// The returned slice `candidates[e]` MUST include `labels[e]` (staying put
1377/// is always legal) and MUST index into the current 0..k label space.
1378pub trait CandidateProposer {
1379    fn propose(&self, labels: &[usize]) -> Vec<Vec<usize>>;
1380}
1381
1382/// Veto individual moves after they've been picked by the sweep.
1383///
1384/// Called once per *proposed* accepted move (`from != to`) before the move
1385/// is applied to the sufficient statistics. For the two moves of an
1386/// exchange between two blocks, the second is asked about with the first
1387/// already applied, and the first is undone if the second is vetoed. The
1388/// destination label is
1389/// intentionally not passed — guards so far only need the source cluster
1390/// (e.g. articulation tests ask "does removing this entity disconnect
1391/// `from`?"). Add a destination-aware variant if a concrete use case
1392/// surfaces.
1393pub trait MoveGuard {
1394    fn accept_move(&self, entity: usize, from: usize, labels: &[usize]) -> bool;
1395}
1396
1397/// Trivial guard that accepts every move. Zero-cost default used by the
1398/// unguarded [`refine_with_candidates`] and [`refine_with_proposer`].
1399pub struct NoGuard;
1400
1401impl MoveGuard for NoGuard {
1402    #[inline(always)]
1403    fn accept_move(&self, _e: usize, _from: usize, _labels: &[usize]) -> bool {
1404        true
1405    }
1406}
1407
1408/// Intersect a per-entity sibling set with a per-entity neighbor-group set,
1409/// with sibling fallback when the intersection is empty and guaranteed
1410/// inclusion of the entity's current label so staying put is always a
1411/// legal move.
1412///
1413/// Shared between [`crate::alg::refine_multilevel::BbknnProposer`] and pinto's
1414/// `GraphProposer` — both proposers differ only in how they gather
1415/// `neighbor_groups`, not in how they combine with siblings.
1416///
1417/// * `siblings` — sorted, deduped sibling group list.
1418/// * `neighbor_groups` — sorted, deduped group list from the proposer's
1419///   spatial/structural neighborhood.
1420/// * `current` — the entity's current label; appended if the intersection
1421///   excluded it.
1422pub fn intersect_with_siblings_fallback(
1423    siblings: &[usize],
1424    neighbor_groups: &[usize],
1425    current: usize,
1426) -> Vec<usize> {
1427    if siblings.is_empty() {
1428        return Vec::new();
1429    }
1430    if siblings.len() == 1 {
1431        return siblings.to_vec();
1432    }
1433    let intersect: Vec<usize> = siblings
1434        .iter()
1435        .copied()
1436        .filter(|g| neighbor_groups.binary_search(g).is_ok())
1437        .collect();
1438    if intersect.is_empty() {
1439        return siblings.to_vec();
1440    }
1441    if intersect.contains(&current) {
1442        intersect
1443    } else {
1444        let mut c = intersect;
1445        c.push(current);
1446        c.sort_unstable();
1447        c
1448    }
1449}
1450
1451///////////////////
1452// Sweep drivers //
1453///////////////////
1454
1455/// Shared sweep-driver context bundling the readonly inputs that every
1456/// `refine_with_*` variant needs. Kept as a struct so the driver fns don't
1457/// exceed clippy's argument-count threshold.
1458pub struct RefineContext<'a> {
1459    pub profiles: &'a Profiles,
1460    pub k: usize,
1461    pub params: &'a RefineParams,
1462    pub level_label: &'a str,
1463}
1464
1465/// Outcome of one sweep over all entities.
1466#[derive(Default, Clone, Copy)]
1467struct SweepCounts {
1468    moves: usize,
1469    vetoed: usize,
1470}
1471
1472/// Member-count limits of every block, fixed from the partition a
1473/// refinement starts with.
1474///
1475/// With `n₀` a block's starting size and `n̄` the mean size of the occupied
1476/// blocks, a block is never taken below
1477/// `max(1, ceil(size_floor · min(n₀, n̄)))` members nor above
1478/// `ceil(size_cap · max(n₀, n̄))`. A block larger than the mean can give
1479/// members down to the mean and one smaller than the mean can take them up
1480/// to it, so sizes may even out, but a small block cannot be drained nor a
1481/// large one keep growing. A floor of at least one member keeps every
1482/// block occupied.
1483///
1484/// Rounded up, the floor of a small block is its starting size (below ten
1485/// members at the default nine tenths): such a block does not shrink. It
1486/// still takes members up to its cap, gives one for each it has taken, and
1487/// trades a member for one of another block (see [`Mover`]), so a member
1488/// that does not fit leaves when another comes for its place.
1489///
1490/// The fractions are read in millionths and the products taken in
1491/// integers, so that a decimal fraction gives the limit it gives on paper
1492/// (`0.8 × 10` is 8, where the `f32` nearest to 0.8 widened to `f64` is a
1493/// little above 0.8, and rounds up to 9).
1494///
1495/// The limits are checked against the live counts when a move is applied,
1496/// not when it is proposed: the proposals of a Jacobi sweep are made
1497/// against one snapshot and would otherwise all pass together.
1498struct SizeLimits {
1499    floor: Vec<usize>,
1500    cap: Vec<usize>,
1501}
1502
1503/// `x` in millionths; zero for a negative or undefined `x`.
1504fn millionths(x: f32) -> u128 {
1505    (x as f64 * 1e6).round().max(0.0) as u128
1506}
1507
1508impl SizeLimits {
1509    fn new(count: &[usize], params: &RefineParams) -> Self {
1510        const UNIT: u128 = 1_000_000;
1511        // Sizes as multiples of 1 / occupied, in which the mean is `total`.
1512        let occupied = count.iter().filter(|&&c| c > 0).count().max(1) as u128;
1513        let total = count.iter().sum::<usize>() as u128;
1514        let size_floor = millionths(params.size_floor);
1515        let size_cap = millionths(params.size_cap);
1516        // `ceil(frac · base / occupied)` members, `base` a size times `occupied`.
1517        let limit = |frac: u128, base: u128| (frac * base).div_ceil(UNIT * occupied) as usize;
1518        let (floor, cap) = count
1519            .iter()
1520            .map(|&n0| {
1521                let n0 = n0 as u128 * occupied;
1522                let floor = if size_floor > 0 {
1523                    limit(size_floor, n0.min(total)).max(1)
1524                } else {
1525                    0
1526                };
1527                let cap = if size_cap > 0 {
1528                    limit(size_cap, n0.max(total))
1529                } else {
1530                    usize::MAX
1531                };
1532                (floor, cap)
1533            })
1534            .unzip();
1535        Self { floor, cap }
1536    }
1537
1538    /// May a member leave `from` at all, given the member counts?
1539    #[inline]
1540    fn can_leave(&self, count: &[usize], from: usize) -> bool {
1541        count[from] > self.floor[from]
1542    }
1543
1544    /// May a member move `from → to`, given the member counts?
1545    #[inline]
1546    fn admits(&self, count: &[usize], from: usize, to: usize) -> bool {
1547        self.can_leave(count, from) && count[to] < self.cap[to]
1548    }
1549}
1550
1551/// What every sweep of one refinement reads.
1552struct SweepEnv<'a, G> {
1553    candidates: &'a [Vec<usize>],
1554    profiles: &'a Profiles,
1555    limits: &'a SizeLimits,
1556    guard: &'a G,
1557}
1558
1559/// Buffers of the sweeps, allocated once per refinement.
1560struct SweepScratch {
1561    /// Destination each entity proposes in a Jacobi sweep (its current
1562    /// block: no move).
1563    proposals: Vec<usize>,
1564    /// Score gain of each proposed move over staying.
1565    gains: Vec<f32>,
1566    /// Entities that proposed a move, in the order they are applied.
1567    movers: Vec<u32>,
1568    /// Movers the size limits refused on their turn.
1569    deferred: Vec<u32>,
1570    /// Blocks a move of this sweep has already changed.
1571    changed: Vec<bool>,
1572    /// Scores of one entity, by block.
1573    log_probs: Vec<f64>,
1574    /// Refused moves by `(from, to)`, each waiting for one the other way.
1575    waiting: HashMap<(usize, usize), VecDeque<u32>>,
1576}
1577
1578impl SweepScratch {
1579    fn new(num_entities: usize, k: usize, parallel: bool) -> Self {
1580        let per_entity = if parallel { num_entities } else { 0 };
1581        Self {
1582            proposals: vec![0; per_entity],
1583            gains: vec![0.0; per_entity],
1584            movers: Vec::with_capacity(per_entity),
1585            deferred: Vec::new(),
1586            changed: vec![false; k],
1587            log_probs: vec![f64::NEG_INFINITY; k],
1588            waiting: HashMap::default(),
1589        }
1590    }
1591}
1592
1593/// Applies the moves of one sweep to the live statistics, one at a time.
1594///
1595/// A move the size limits admit is applied if the guard accepts it. A move
1596/// they refuse waits: when a move the other way between the same two blocks
1597/// turns up, the two are applied together as an exchange, which changes no
1598/// member count. A block at its floor, as every small block is, can
1599/// thereby still trade a member that does not fit for one that does.
1600struct Mover<'a, G> {
1601    env: &'a SweepEnv<'a, G>,
1602    stats: &'a mut DcPoissonStats,
1603    changed: &'a mut [bool],
1604    log_probs: &'a mut [f64],
1605    waiting: &'a mut HashMap<(usize, usize), VecDeque<u32>>,
1606    counts: SweepCounts,
1607}
1608
1609impl<'a, G: MoveGuard> Mover<'a, G> {
1610    /// A mover for one sweep, with no move left waiting from an earlier one.
1611    fn new(
1612        env: &'a SweepEnv<'a, G>,
1613        stats: &'a mut DcPoissonStats,
1614        changed: &'a mut [bool],
1615        log_probs: &'a mut [f64],
1616        waiting: &'a mut HashMap<(usize, usize), VecDeque<u32>>,
1617    ) -> Self {
1618        waiting.clear();
1619        Self {
1620            env,
1621            stats,
1622            changed,
1623            log_probs,
1624            waiting,
1625            counts: SweepCounts::default(),
1626        }
1627    }
1628
1629    /// Does `e`, a member of `from`, score higher in `to` on the live
1630    /// statistics? A score that does not compare is no gain.
1631    fn gains(&mut self, e: usize, from: usize, to: usize) -> bool {
1632        compute_log_probs_restricted(
1633            e,
1634            self.stats,
1635            self.env.profiles,
1636            &[from, to],
1637            self.log_probs,
1638        );
1639        self.log_probs[to] > self.log_probs[from]
1640    }
1641
1642    /// Ask the guard about moving `e` out of `from`.
1643    fn accepted(&mut self, e: usize, from: usize) -> bool {
1644        let yes = self.env.guard.accept_move(e, from, &self.stats.membership);
1645        if !yes {
1646            self.counts.vetoed += 1;
1647        }
1648        yes
1649    }
1650
1651    fn apply(&mut self, e: usize, from: usize, to: usize) {
1652        self.stats.delta_move(e, from, to, self.env.profiles);
1653        self.changed[from] = true;
1654        self.changed[to] = true;
1655    }
1656
1657    /// Settle the move of `e` to block `to`. With `recheck`, the move was
1658    /// scored against statistics that have changed since, and is dropped
1659    /// unless it still gains.
1660    ///
1661    /// Returns whether the move was refused by the size limits and left
1662    /// waiting for a counterpart; otherwise it was applied (alone or as one
1663    /// half of an exchange), or dropped for want of gain or by the guard.
1664    fn settle(&mut self, e: usize, to: usize, recheck: bool) -> bool {
1665        let from = self.stats.membership[e];
1666        if from == to || (recheck && !self.gains(e, from, to)) {
1667            return false;
1668        }
1669        if self.env.limits.admits(&self.stats.count, from, to) {
1670            if self.accepted(e, from) {
1671                self.apply(e, from, to);
1672                self.counts.moves += 1;
1673            }
1674            return false;
1675        }
1676        // An exchange with a member of `to` that waits to come here: tried
1677        // with `e` moved, and undone unless the counterpart then gains and
1678        // may go.
1679        while let Some(f) = self
1680            .waiting
1681            .get_mut(&(to, from))
1682            .and_then(VecDeque::pop_front)
1683        {
1684            let f = f as usize;
1685            if self.stats.membership[f] != to {
1686                continue;
1687            }
1688            if !self.accepted(e, from) {
1689                self.waiting
1690                    .entry((to, from))
1691                    .or_default()
1692                    .push_front(f as u32);
1693                return false;
1694            }
1695            self.stats.delta_move(e, from, to, self.env.profiles);
1696            if self.gains(f, to, from) && self.accepted(f, to) {
1697                self.apply(f, to, from);
1698                self.counts.moves += 2;
1699                return false;
1700            }
1701            self.stats.delta_move(e, to, from, self.env.profiles);
1702        }
1703        self.waiting
1704            .entry((from, to))
1705            .or_default()
1706            .push_back(e as u32);
1707        true
1708    }
1709}
1710
1711/// Apply the proposals of a Jacobi sweep to `stats`, largest gain first.
1712///
1713/// Every proposal was scored against the pre-sweep snapshot, so it is only
1714/// as good as that snapshot still is when its turn comes:
1715/// - the size limits are read off the live member counts;
1716/// - a move between blocks that an earlier move of this sweep has changed
1717///   is scored again on the live statistics and dropped unless it still
1718///   gains. Without this, entities that each want to leave the same
1719///   company all leave together and all come back the next sweep.
1720///
1721/// Applying in order of gain makes a mixed block shed its worst-fitting
1722/// members first; the members that fit are re-scored against what is left
1723/// and stay.
1724///
1725/// A move the limits refuse on its turn is tried once more after the
1726/// others, when the block it leaves may have taken a member (see [`Mover`]
1727/// for the exchanges).
1728fn apply_proposals<G: MoveGuard>(
1729    env: &SweepEnv<G>,
1730    scratch: &mut SweepScratch,
1731    stats: &mut DcPoissonStats,
1732) -> SweepCounts {
1733    let SweepScratch {
1734        proposals,
1735        gains,
1736        movers,
1737        deferred,
1738        changed,
1739        log_probs,
1740        waiting,
1741    } = scratch;
1742    movers.clear();
1743    movers.extend(
1744        proposals
1745            .iter()
1746            .zip(&stats.membership)
1747            .enumerate()
1748            .filter(|(_, (new, old))| new != old)
1749            .map(|(e, _)| e as u32),
1750    );
1751    movers.sort_unstable_by(|&a, &b| {
1752        gains[b as usize]
1753            .total_cmp(&gains[a as usize])
1754            .then(a.cmp(&b))
1755    });
1756    changed.iter_mut().for_each(|c| *c = false);
1757    deferred.clear();
1758    let mut mover = Mover::new(env, stats, changed, log_probs, waiting);
1759    for &e in movers.iter() {
1760        let (e, to) = (e as usize, proposals[e as usize]);
1761        let from = mover.stats.membership[e];
1762        let recheck = mover.changed[from] || mover.changed[to];
1763        if mover.settle(e, to, recheck) {
1764            deferred.push(e as u32);
1765        }
1766    }
1767    if mover.counts.moves > 0 {
1768        mover.waiting.clear();
1769        for &e in deferred.iter() {
1770            mover.settle(e as usize, proposals[e as usize], true);
1771        }
1772    }
1773    mover.counts
1774}
1775
1776/// Gauss–Seidel sequential sweep. For each entity visited in `order`, scores
1777/// the candidate destinations under the live (mutated) stats, picks one via
1778/// `pick(log_probs, candidates, current_label)`, and applies the move through
1779/// the size limits and the guard ([`Mover`]). Gibbs callers shuffle `order`
1780/// and pass a sampling closure; greedy callers pass `0..n` and an argmax
1781/// closure.
1782fn sweep_sequential<G, F, I>(
1783    env: &SweepEnv<G>,
1784    stats: &mut DcPoissonStats,
1785    scratch: &mut SweepScratch,
1786    order: I,
1787    mut pick: F,
1788) -> SweepCounts
1789where
1790    G: MoveGuard,
1791    F: FnMut(&[f64], &[usize], usize) -> usize,
1792    I: IntoIterator<Item = usize>,
1793{
1794    let mut mover = Mover::new(
1795        env,
1796        stats,
1797        &mut scratch.changed,
1798        &mut scratch.log_probs,
1799        &mut scratch.waiting,
1800    );
1801    for e in order {
1802        let cand = &env.candidates[e];
1803        if cand.len() < 2 {
1804            continue;
1805        }
1806        let old = mover.stats.membership[e];
1807        compute_log_probs_restricted(e, mover.stats, env.profiles, cand, mover.log_probs);
1808        let new = pick(mover.log_probs, cand, old);
1809        // Picked on the live statistics: nothing to check again.
1810        mover.settle(e, new, false);
1811    }
1812    mover.counts
1813}
1814
1815/// Jacobi sweep: every entity computes its proposal against a frozen pre-sweep
1816/// snapshot of `stats` (rayon `par_iter`); proposals are then applied
1817/// sequentially by [`apply_proposals`].
1818///
1819/// `pick(log_probs, cand, e, current)` chooses the destination given the
1820/// restricted log-likelihoods. For Gibbs it derives a per-entity RNG from
1821/// `(sweep_seed, e)` and samples; for greedy it ignores `e` and argmaxes.
1822fn sweep_jacobi<G: MoveGuard, F>(
1823    env: &SweepEnv<G>,
1824    stats: &mut DcPoissonStats,
1825    scratch: &mut SweepScratch,
1826    pick: F,
1827) -> SweepCounts
1828where
1829    F: Fn(&[f64], &[usize], usize, usize) -> usize + Sync,
1830{
1831    let SweepEnv {
1832        candidates,
1833        profiles,
1834        ..
1835    } = *env;
1836    debug_assert_eq!(scratch.proposals.len(), candidates.len());
1837    {
1838        // Immutable reborrow so the rayon closure can capture `&stats: Sync`
1839        // while the outer `&mut` is held across the propose / apply phases.
1840        let stats: &DcPoissonStats = stats;
1841        let k = stats.k;
1842        scratch
1843            .proposals
1844            .par_iter_mut()
1845            .zip(scratch.gains.par_iter_mut())
1846            .enumerate()
1847            .with_min_len(256)
1848            .for_each_init(
1849                || vec![f64::NEG_INFINITY; k],
1850                |log_probs, (e, (prop, gain))| {
1851                    let cand = &candidates[e];
1852                    let current = stats.membership[e];
1853                    *prop = current;
1854                    // Members of a block at its floor propose too: the
1855                    // block may take a member earlier in the same sweep.
1856                    if cand.len() < 2 {
1857                        return;
1858                    }
1859                    compute_log_probs_restricted(e, stats, profiles, cand, log_probs);
1860                    let new = pick(log_probs, cand, e, current);
1861                    if new != current {
1862                        *prop = new;
1863                        *gain = (log_probs[new] - staying_score(log_probs, cand, current)) as f32;
1864                    }
1865                },
1866            );
1867    }
1868    apply_proposals(env, scratch, stats)
1869}
1870
1871/// Refine `labels` in place for one level by running Gibbs + greedy sweeps
1872/// over `profiles` with the given pre-built `candidates` and a custom
1873/// [`MoveGuard`]. Returns total accepted moves across all sweeps.
1874///
1875/// An entity moves only to a block that scores above its current one by
1876/// more than the noise of the two block estimates (see [`DcPoissonStats`]);
1877/// equal scores keep it where it is. Gibbs sweeps draw among such blocks,
1878/// greedy sweeps take the best, and greedy sweeps stop at the first one
1879/// that moves nothing. A move is applied only when the size limits of
1880/// [`RefineParams::size_floor`] / [`RefineParams::size_cap`] allow it, on
1881/// its own or together with a move the other way between the same two
1882/// blocks, and `guard.accept_move(...)` returns `true`; otherwise the
1883/// entity stays in its current group for this sweep.
1884///
1885/// The limits are those of the sizes `labels` comes in with, so they bound
1886/// this call, not a sequence of calls on its own output (see
1887/// [`RefineParams::size_floor`]).
1888///
1889/// This is the lowest-level driver. Pass [`NoGuard`] to get the unguarded
1890/// behavior; most callers should prefer the convenience wrappers
1891/// [`refine_with_candidates`] / [`refine_with_proposer`] /
1892/// [`refine_with_proposer_guarded`].
1893///
1894/// Sweep dispatch follows `ctx.params.parallel`: Jacobi (parallel proposal,
1895/// sequential apply) when true, Gauss–Seidel (single-threaded) when false.
1896/// See [`RefineParams::parallel`] for trade-offs.
1897pub fn refine_with_candidates_guarded<G: MoveGuard>(
1898    labels: &mut [usize],
1899    candidates: &[Vec<usize>],
1900    guard: &G,
1901    rng: &mut SmallRng,
1902    ctx: &RefineContext,
1903) -> usize {
1904    let RefineContext {
1905        profiles,
1906        k,
1907        params,
1908        level_label,
1909    } = *ctx;
1910    let num_entities = labels.len();
1911    let mut stats = DcPoissonStats::from_profiles_with(profiles, k, labels, params);
1912    let limits = SizeLimits::new(&stats.count, params);
1913    let env = SweepEnv {
1914        candidates,
1915        profiles,
1916        limits: &limits,
1917        guard,
1918    };
1919    let mut total_moves = 0usize;
1920    let mut total_vetoed = 0usize;
1921
1922    // Reusable buffers, hoisted so that no sweep allocates anew: the
1923    // proposals of Jacobi sweeps, or the visiting order of sequential ones
1924    // (greedy sequential iterates `0..num_entities` directly and ignores
1925    // the order).
1926    let mut scratch = SweepScratch::new(num_entities, k, params.parallel);
1927    let mut order: Vec<usize> = if !params.parallel {
1928        (0..num_entities).collect()
1929    } else {
1930        Vec::new()
1931    };
1932
1933    let max_sweeps = (params.num_gibbs + params.num_greedy) as u64;
1934    let prog_bar = legume_numeric::matrix::progress::new_progress_bar(max_sweeps)
1935        .with_message(format!("{level_label} sweeps"));
1936    // Refresh on a timer so the bar visibly animates between sweep ticks
1937    // (a single sweep at large D can take several seconds — without this
1938    // the bar is silent until the next inc(1)).
1939    prog_bar.enable_steady_tick(std::time::Duration::from_millis(100));
1940
1941    // Jacobi per-sweep seed base: draw a single odd u64 from `rng` so the
1942    // sweep loop is reproducible given `params.seed` regardless of how many
1943    // cells `shuffle` would have consumed in the sequential path.
1944    let jacobi_base_seed = rng.random::<u64>() | 1;
1945
1946    if params.num_gibbs > 0 {
1947        let mut low_sweeps = 0usize;
1948        for sweep in 0..params.num_gibbs {
1949            let sc = if params.parallel {
1950                let sweep_seed = jacobi_base_seed.wrapping_mul(sweep as u64 + 1);
1951                sweep_jacobi(
1952                    &env,
1953                    &mut stats,
1954                    &mut scratch,
1955                    |log_probs, cand, e, current| {
1956                        let vertex_seed = sweep_seed ^ (e as u64).wrapping_mul(2654435761);
1957                        let mut rng = SmallRng::seed_from_u64(vertex_seed);
1958                        sample_improving_log(log_probs, cand, current, &mut rng)
1959                    },
1960                )
1961            } else {
1962                order.shuffle(rng);
1963                sweep_sequential(
1964                    &env,
1965                    &mut stats,
1966                    &mut scratch,
1967                    order.iter().copied(),
1968                    |log_probs, cand, current| sample_improving_log(log_probs, cand, current, rng),
1969                )
1970            };
1971            total_moves += sc.moves;
1972            total_vetoed += sc.vetoed;
1973            prog_bar.inc(1);
1974            if params.gibbs_stagnation > 0.0 {
1975                if (sc.moves as f64) < params.gibbs_stagnation * (num_entities as f64) {
1976                    low_sweeps += 1;
1977                    if low_sweeps >= 3 {
1978                        break;
1979                    }
1980                } else {
1981                    low_sweeps = 0;
1982                }
1983            }
1984        }
1985    }
1986
1987    for _sweep in 0..params.num_greedy {
1988        let sc = if params.parallel {
1989            sweep_jacobi(
1990                &env,
1991                &mut stats,
1992                &mut scratch,
1993                |log_probs, cand, _e, current| argmax_log_staying(log_probs, cand, current),
1994            )
1995        } else {
1996            sweep_sequential(
1997                &env,
1998                &mut stats,
1999                &mut scratch,
2000                0..num_entities,
2001                argmax_log_staying,
2002            )
2003        };
2004        total_moves += sc.moves;
2005        total_vetoed += sc.vetoed;
2006        prog_bar.inc(1);
2007        if sc.moves == 0 {
2008            break;
2009        }
2010    }
2011    prog_bar.finish_and_clear();
2012
2013    if total_vetoed > 0 {
2014        log::debug!(
2015            "{}: {} moves, {} vetoed by MoveGuard",
2016            level_label,
2017            total_moves,
2018            total_vetoed
2019        );
2020    }
2021
2022    labels.copy_from_slice(&stats.membership);
2023    total_moves
2024}
2025
2026/// Unguarded variant of [`refine_with_candidates_guarded`].
2027pub fn refine_with_candidates(
2028    labels: &mut [usize],
2029    candidates: &[Vec<usize>],
2030    rng: &mut SmallRng,
2031    ctx: &RefineContext,
2032) -> usize {
2033    refine_with_candidates_guarded(labels, candidates, &NoGuard, rng, ctx)
2034}
2035
2036/// Generic driver: ask `proposer` for candidates, then run the guarded sweep.
2037pub fn refine_with_proposer_guarded<P: CandidateProposer, G: MoveGuard>(
2038    labels: &mut [usize],
2039    proposer: &P,
2040    guard: &G,
2041    rng: &mut SmallRng,
2042    ctx: &RefineContext,
2043) -> usize {
2044    let candidates = proposer.propose(labels);
2045    let moves = refine_with_candidates_guarded(labels, &candidates, guard, rng, ctx);
2046    info!("  {}: {} DC-Poisson moves", ctx.level_label, moves);
2047    moves
2048}
2049
2050/// Unguarded generic driver: ask `proposer` for candidates, then run the
2051/// sweep with [`NoGuard`].
2052pub fn refine_with_proposer<P: CandidateProposer>(
2053    labels: &mut [usize],
2054    proposer: &P,
2055    rng: &mut SmallRng,
2056    ctx: &RefineContext,
2057) -> usize {
2058    let candidates = proposer.propose(labels);
2059    let moves = refine_with_candidates(labels, &candidates, rng, ctx);
2060    info!("  {}: {} DC-Poisson moves", ctx.level_label, moves);
2061    moves
2062}
2063
2064////////////////////////////
2065// Tests (algorithm core) //
2066////////////////////////////
2067
2068#[cfg(test)]
2069#[path = "dc_poisson_tests.rs"]
2070mod tests;