Skip to main content

data_beans/alg/
random_projection.rs

1#![allow(dead_code)]
2
3use crate::alg::batch_mixing::*;
4use crate::sparse_data_visitors::*;
5use crate::sparse_io_stack::SparseIoStack;
6use crate::sparse_io_vector::SparseIoVec;
7use legume_numeric::matrix::dmatrix_util::*;
8use legume_numeric::matrix::rand_util::mix_seed;
9use std::sync::{Arc, Mutex};
10
11use legume_numeric::matrix::traits::*;
12use log::{info, warn};
13use nalgebra::DVector;
14
15/// Bundle passed as `SharedIn` so the visitor signature stays compatible
16/// with `visit_columns_by_block`. The basis is stored as `K × D`
17/// (transposed) so the inner kernel accesses contiguous K-vectors per
18/// non-zero, instead of stride-D reads across `D × K` column-major
19/// storage.
20struct ProjVisitorIn<'a> {
21    basis_kd: &'a nalgebra::DMatrix<f32>,
22}
23
24pub struct RandColProjOut {
25    pub basis: nalgebra::DMatrix<f32>,
26    pub proj: nalgebra::DMatrix<f32>,
27}
28
29pub struct RandRowProjOut {
30    pub basis: nalgebra::DMatrix<f32>,
31    pub proj: nalgebra::DMatrix<f32>,
32}
33
34/// Default random-basis seed for the non-seeded projection entry points.
35///
36/// The random-projection basis only needs to be *stable* (a fixed
37/// Johnson–Lindenstrauss draw is still a valid projection), so callers that do
38/// not thread their own `--seed` get a pinned, reproducible basis for free.
39/// Callers that want `--seed` to also drive the projection use the `_seeded`
40/// variants (e.g. the `graph_embedding_util::fit` path behind `senna bge`).
41pub const DEFAULT_PROJECTION_SEED: u64 = 0x50524F4A_50524F4A; // "PROJPROJ"
42
43pub trait RandProjOps {
44    ///
45    /// Create K x ncol projection by concatenating data across
46    /// columns and returns the random basis matrix `D` x `target_dim`
47    /// and the projection result `target_dim` x `N`
48    ///
49    /// # Arguments
50    /// * `target_dim`: target dimensionality
51    /// * `block_size`: block size for parallel computation
52    ///
53    /// # Output
54    /// * `basis`: `D x K` random basis matrix
55    /// * `proj`: `K x N` projection results
56    ///
57    /// Uses [`DEFAULT_PROJECTION_SEED`] for the basis; for a caller-controlled
58    /// seed see [`RandProjOps::project_columns_with_batch_correction_seeded`].
59    fn project_columns(
60        &self,
61        target_dim: usize,
62        block_size: Option<usize>,
63    ) -> anyhow::Result<RandColProjOut> {
64        self.project_columns_with_batch_correction::<usize>(target_dim, block_size, None)
65    }
66
67    ///
68    /// Create K x ncol projection by concatenating data across
69    /// columns and returns the random basis matrix `D` x `target_dim`
70    /// and the projection result `target_dim` x `N`
71    ///
72    /// # Arguments
73    /// * `target_dim`: target dimensionality
74    /// * `block_size`: block size for parallel computation
75    ///
76    /// # Output
77    /// * `basis`: `D x K` random basis matrix
78    /// * `proj`: `K x N` projection results
79    ///
80    /// Uses [`DEFAULT_PROJECTION_SEED`]; delegates to
81    /// [`RandProjOps::project_columns_with_batch_correction_seeded`].
82    fn project_columns_with_batch_correction<T>(
83        &self,
84        target_dim: usize,
85        block_size: Option<usize>,
86        batch_membership: Option<&[T]>,
87    ) -> anyhow::Result<RandColProjOut>
88    where
89        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
90    {
91        self.project_columns_with_batch_correction_seeded(
92            target_dim,
93            block_size,
94            batch_membership,
95            DEFAULT_PROJECTION_SEED,
96        )
97    }
98
99    /// Seeded variant of [`RandProjOps::project_columns_with_batch_correction`]:
100    /// `seed` pins the random basis so identical-config runs are byte-identical.
101    fn project_columns_with_batch_correction_seeded<T>(
102        &self,
103        target_dim: usize,
104        block_size: Option<usize>,
105        batch_membership: Option<&[T]>,
106        seed: u64,
107    ) -> anyhow::Result<RandColProjOut>
108    where
109        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString;
110
111    /// Like `project_columns_with_batch_correction` but with per-row
112    /// (per-feature) weights applied to the random basis. Features with
113    /// weight 0 are excluded from the projection geometry.
114    ///
115    /// Uses [`DEFAULT_PROJECTION_SEED`]; delegates to
116    /// [`RandProjOps::project_columns_weighted_seeded`].
117    fn project_columns_weighted<T>(
118        &self,
119        target_dim: usize,
120        block_size: Option<usize>,
121        batch_membership: Option<&[T]>,
122        row_weights: &[f32],
123    ) -> anyhow::Result<RandColProjOut>
124    where
125        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
126    {
127        self.project_columns_weighted_seeded(
128            target_dim,
129            block_size,
130            batch_membership,
131            row_weights,
132            DEFAULT_PROJECTION_SEED,
133        )
134    }
135
136    /// Seeded variant of [`RandProjOps::project_columns_weighted`]: `seed` pins
137    /// the random basis so identical-config runs are byte-identical.
138    fn project_columns_weighted_seeded<T>(
139        &self,
140        target_dim: usize,
141        block_size: Option<usize>,
142        batch_membership: Option<&[T]>,
143        row_weights: &[f32],
144        seed: u64,
145    ) -> anyhow::Result<RandColProjOut>
146    where
147        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString;
148
149    /// Assign each column/cell to random group by binary encoding
150    ///
151    /// # Arguments
152    ///
153    /// * `proj_kn` - random projection matrix (feature x column/cell)
154    ///
155    /// * `num_features` - number of top features (if None, use all of them)
156    ///
157    fn partition_columns_to_groups(
158        &mut self,
159        proj_kn: &nalgebra::DMatrix<f32>,
160        num_features: Option<usize>,
161        ncols_per_group: Option<usize>,
162    ) -> anyhow::Result<usize>;
163
164    /// Like [`RandProjOps::partition_columns_to_groups`], but bins that hold
165    /// fewer than `min_batches` batches merge up the code tree, for at most
166    /// `merge_levels` levels (see [`crate::alg::batch_mixing`]).
167    /// `min_batches` is capped by the number of batches. Returns the number
168    /// of groups.
169    fn partition_columns_to_mixed_groups<T>(
170        &mut self,
171        proj_kn: &nalgebra::DMatrix<f32>,
172        num_features: Option<usize>,
173        batch_membership: &[T],
174        min_batches: usize,
175        merge_levels: usize,
176    ) -> anyhow::Result<usize>
177    where
178        T: std::hash::Hash + Eq + Clone,
179    {
180        let part = mixed_partition(
181            proj_kn,
182            num_features,
183            batch_membership,
184            min_batches,
185            merge_levels,
186        )?;
187        self.assign_group_labels(&part.labels);
188        Ok(part.num_groups)
189    }
190
191    /// Assign each column to the group of its label.
192    fn assign_group_labels(&mut self, labels: &[usize]);
193
194    // Take samples assigned
195    // fn groups_assigned(&self) -> anyhow::Result<&Vec<usize>>;
196}
197
198/// column-wise visitor for random projection
199fn project_columns_visitor(
200    job: (usize, usize),
201    data_vec: &SparseIoVec,
202    shared_in: &ProjVisitorIn<'_>,
203    arc_proj_kn: Arc<Mutex<&mut nalgebra::DMatrix<f32>>>,
204) -> anyhow::Result<()> {
205    let (lb, ub) = job;
206    let basis_kd = shared_in.basis_kd;
207    let target_dim = basis_kd.nrows();
208    let n_block = ub - lb;
209
210    let mut xx_dm = data_vec.read_columns_csc(lb..ub)?;
211    for x in xx_dm.values_mut() {
212        *x = x.ln_1p();
213    }
214    xx_dm.normalize_columns_inplace();
215
216    // Direct K×N kernel: chunk[:, j] = Σᵢ X[i,j] · B_kd[:, i]. One pass
217    // over CSC non-zeros. Both column slices are unit-stride (contiguous),
218    // so the SAXPY vectorizes; no sparse transpose, no dense transpose.
219    let mut chunk = nalgebra::DMatrix::<f32>::zeros(target_dim, n_block);
220    for (j, col) in xx_dm.col_iter().enumerate() {
221        for (&i, &v) in col.row_indices().iter().zip(col.values()) {
222            chunk.column_mut(j).axpy(v, &basis_kd.column(i), 1.0);
223        }
224    }
225
226    let mut proj_kn = arc_proj_kn.lock().expect("proj_kn lock");
227    proj_kn.columns_range_mut(lb..ub).copy_from(&chunk);
228    Ok(())
229}
230
231impl RandProjOps for SparseIoStack {
232    fn project_columns_with_batch_correction_seeded<T>(
233        &self,
234        target_dim: usize,
235        block_size: Option<usize>,
236        batch_membership: Option<&[T]>,
237        seed: u64,
238    ) -> anyhow::Result<RandColProjOut>
239    where
240        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
241    {
242        let batch_membership = membership_of_columns(batch_membership, self.num_columns()?);
243
244        let proj_vec = self
245            .stack
246            .iter()
247            .enumerate()
248            .map(|(m, data_vec)| -> anyhow::Result<_> {
249                // Distinct sub-seed per stacked modality so same-sized modalities
250                // do not share a basis.
251                data_vec.project_columns_with_batch_correction_seeded(
252                    target_dim,
253                    block_size,
254                    batch_membership,
255                    mix_seed(seed, m as u64),
256                )
257            })
258            .collect::<anyhow::Result<Vec<_>>>()?;
259
260        stack_projections(proj_vec)
261    }
262
263    fn project_columns_weighted_seeded<T>(
264        &self,
265        target_dim: usize,
266        block_size: Option<usize>,
267        batch_membership: Option<&[T]>,
268        row_weights: &[f32],
269        seed: u64,
270    ) -> anyhow::Result<RandColProjOut>
271    where
272        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
273    {
274        let batch_membership = membership_of_columns(batch_membership, self.num_columns()?);
275
276        // Interpret `row_weights` as the concatenated feature axis across
277        // all stacked modalities, in `self.stack` order. Slice per
278        // modality so each per-vec call sees weights of length equal to
279        // its own `num_rows()`.
280        let total_rows: usize = self.stack.iter().map(|v| v.num_rows()).sum();
281        if row_weights.len() != total_rows {
282            return Err(anyhow::anyhow!(
283                "row_weights length {} does not match total feature count {} \
284                 (sum of num_rows() across the stack)",
285                row_weights.len(),
286                total_rows
287            ));
288        }
289
290        let mut offset = 0usize;
291        let mut proj_vec = Vec::with_capacity(self.stack.len());
292        for (m, data_vec) in self.stack.iter().enumerate() {
293            let nrows = data_vec.num_rows();
294            let slice = &row_weights[offset..offset + nrows];
295            offset += nrows;
296            proj_vec.push(data_vec.project_columns_weighted_seeded(
297                target_dim,
298                block_size,
299                batch_membership,
300                slice,
301                mix_seed(seed, m as u64),
302            )?);
303        }
304
305        stack_projections(proj_vec)
306    }
307
308    fn partition_columns_to_groups(
309        &mut self,
310        proj_kn: &nalgebra::DMatrix<f32>,
311        num_sorting_features: Option<usize>,
312        ncols_per_group: Option<usize>,
313    ) -> anyhow::Result<usize> {
314        let nn = proj_kn.ncols();
315        if nn != self.num_columns()? {
316            return Err(anyhow::anyhow!("number of columns mismatch"));
317        }
318
319        let target_kk = num_sorting_features.unwrap_or(proj_kn.nrows());
320        let kk = proj_kn.nrows().min(target_kk).min(nn);
321
322        let binary_codes = binary_sort_columns(proj_kn, kk)?;
323        let max_group = *binary_codes
324            .iter()
325            .max()
326            .ok_or(anyhow::anyhow!("unable to determine max element"))?;
327
328        for x in self.stack.iter_mut() {
329            x.assign_groups(&binary_codes, ncols_per_group);
330        }
331
332        Ok(max_group + 1)
333    }
334
335    fn assign_group_labels(&mut self, labels: &[usize]) {
336        for x in self.stack.iter_mut() {
337            x.assign_groups(labels, None);
338        }
339    }
340}
341
342impl RandProjOps for SparseIoVec {
343    fn project_columns_with_batch_correction_seeded<T>(
344        &self,
345        target_dim: usize,
346        block_size: Option<usize>,
347        batch_membership: Option<&[T]>,
348        seed: u64,
349    ) -> anyhow::Result<RandColProjOut>
350    where
351        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
352    {
353        let nrows = self.num_rows();
354        let ncols = self.num_columns();
355
356        let mut proj_kn = nalgebra::DMatrix::<f32>::zeros(target_dim, ncols);
357
358        let basis_dk = nalgebra::DMatrix::<f32>::rnorm_seeded(nrows, target_dim, seed);
359        // Carry a K×D copy for the inner kernel so per-non-zero column
360        // slices into the basis are contiguous (unit-stride SAXPY).
361        let basis_kd = basis_dk.transpose();
362
363        let shared_in = ProjVisitorIn {
364            basis_kd: &basis_kd,
365        };
366        info!(
367            "starting random projection loop (project_columns_with_batch_correction): \
368             D={nrows}, N={ncols}, K={target_dim}, block_size={:?}",
369            block_size
370        );
371        self.visit_columns_by_block(
372            &project_columns_visitor,
373            &shared_in,
374            &mut proj_kn,
375            block_size,
376        )?;
377        info!("finished random projection loop");
378
379        if let Some(col_to_batch) = batch_membership {
380            adjust_batch_biases(&mut proj_kn, col_to_batch)?;
381        }
382
383        let (lb, ub) = (-4., 4.);
384        proj_kn.scale_columns_inplace();
385
386        if proj_kn.max() > ub || proj_kn.min() < lb {
387            // info!("Clamping values [{}, {}] after standardization", lb, ub);
388            proj_kn.iter_mut().for_each(|x| {
389                *x = x.clamp(lb, ub);
390            });
391            proj_kn.scale_columns_inplace();
392        }
393        Ok(RandColProjOut {
394            basis: basis_dk,
395            proj: proj_kn,
396        })
397    }
398
399    /// Like `project_columns_with_batch_correction` but with per-row
400    /// (per-feature) weights applied to the random basis. Features with
401    /// weight 0 are excluded from the projection geometry.
402    fn project_columns_weighted_seeded<T>(
403        &self,
404        target_dim: usize,
405        block_size: Option<usize>,
406        batch_membership: Option<&[T]>,
407        row_weights: &[f32],
408        seed: u64,
409    ) -> anyhow::Result<RandColProjOut>
410    where
411        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
412    {
413        let nrows = self.num_rows();
414        let ncols = self.num_columns();
415
416        assert_eq!(row_weights.len(), nrows, "row_weights length mismatch");
417
418        let mut proj_kn = nalgebra::DMatrix::<f32>::zeros(target_dim, ncols);
419
420        let mut basis_dk = nalgebra::DMatrix::<f32>::rnorm_seeded(nrows, target_dim, seed);
421
422        // Zero out basis rows for filtered features
423        for (r, &w) in row_weights.iter().enumerate() {
424            if w <= 0.0 {
425                basis_dk.row_mut(r).fill(0.0);
426            } else if (w - 1.0).abs() > 1e-6 {
427                basis_dk.row_mut(r).scale_mut(w);
428            }
429        }
430
431        let basis_kd = basis_dk.transpose();
432        let shared_in = ProjVisitorIn {
433            basis_kd: &basis_kd,
434        };
435        info!(
436            "starting random projection loop (project_columns_weighted): \
437             D={nrows}, N={ncols}, K={target_dim}, block_size={:?}",
438            block_size
439        );
440        self.visit_columns_by_block(
441            &project_columns_visitor,
442            &shared_in,
443            &mut proj_kn,
444            block_size,
445        )?;
446        info!("finished random projection loop");
447
448        if let Some(col_to_batch) = batch_membership {
449            adjust_batch_biases(&mut proj_kn, col_to_batch)?;
450        }
451
452        let (lb, ub) = (-4., 4.);
453        proj_kn.scale_columns_inplace();
454        if proj_kn.max() > ub || proj_kn.min() < lb {
455            proj_kn.iter_mut().for_each(|x| {
456                *x = x.clamp(lb, ub);
457            });
458            proj_kn.scale_columns_inplace();
459        }
460        Ok(RandColProjOut {
461            basis: basis_dk,
462            proj: proj_kn,
463        })
464    }
465
466    /// Assign each column/cell to a sample by sorting through binary
467    /// encoding
468    ///
469    /// # Arguments
470    ///
471    /// * `proj_kn` - random projection matrix (feature x column/cell)
472    ///
473    /// * `num_features` - number of top features (if None, use all of them)
474    ///
475    fn partition_columns_to_groups(
476        &mut self,
477        proj_kn: &nalgebra::DMatrix<f32>,
478        num_sorting_features: Option<usize>,
479        ncols_per_group: Option<usize>,
480    ) -> anyhow::Result<usize> {
481        let nn = proj_kn.ncols();
482        if nn != self.num_columns() {
483            return Err(anyhow::anyhow!("number of columns mismatch"));
484        }
485
486        let target_kk = num_sorting_features.unwrap_or(proj_kn.nrows());
487        let kk = proj_kn.nrows().min(target_kk).min(nn);
488
489        let binary_codes = binary_sort_columns(proj_kn, kk)?;
490        let max_group = *binary_codes
491            .iter()
492            .max()
493            .ok_or(anyhow::anyhow!("unable to determine max element"))?;
494        self.assign_groups(&binary_codes, ncols_per_group);
495        Ok(max_group + 1)
496    }
497
498    fn assign_group_labels(&mut self, labels: &[usize]) {
499        self.assign_groups(labels, None);
500    }
501}
502
503/// The batch membership to correct a stack of `ncols` columns with. One
504/// longer than `ncols` is cut to its first `ncols` entries, the columns it
505/// has in common with the stack; one shorter leaves some columns without a
506/// batch, so there is no adjustment (`None`). Either comes with a warning.
507fn membership_of_columns<T>(batch_membership: Option<&[T]>, ncols: usize) -> Option<&[T]> {
508    let col_to_batch = batch_membership?;
509    let size = col_to_batch.len();
510    if size > ncols {
511        warn!(
512            "The batch membership size {size} exceeds the number of columns {ncols}; \
513             using its first {ncols} entries ..."
514        );
515    } else if size < ncols {
516        warn!(
517            "The batch membership size {size} falls short of the number of columns {ncols}; \
518             no batch adjustment ..."
519        );
520    }
521    col_to_batch.get(..ncols)
522}
523
524/// The modalities' projections stacked on the feature axis: bases and
525/// projections concatenated vertically, in stack order.
526fn stack_projections(proj_vec: Vec<RandColProjOut>) -> anyhow::Result<RandColProjOut> {
527    let (bases, projs): (Vec<_>, Vec<_>) = proj_vec.into_iter().map(|x| (x.basis, x.proj)).unzip();
528    Ok(RandColProjOut {
529        basis: concatenate_vertical(&bases)?,
530        proj: concatenate_vertical(&projs)?,
531    })
532}
533
534/// Remove batch shifts from a projection, keeping each batch's cell-state
535/// composition: batches are centred within cell state (see
536/// [`crate::alg::batch_mixing`]), not by their plain means. A membership of
537/// the wrong length is ignored with a warning.
538fn adjust_batch_biases<T>(
539    proj_kn: &mut nalgebra::DMatrix<f32>,
540    col_to_batch: &[T],
541) -> anyhow::Result<()>
542where
543    T: std::hash::Hash + Eq + Clone,
544{
545    let ncols = proj_kn.ncols();
546    if col_to_batch.len() != ncols {
547        warn!(
548            "The batch membership size {} mismatches with the number of columns {} ...",
549            col_to_batch.len(),
550            ncols
551        );
552        return Ok(());
553    }
554    let batch = batch_indices(col_to_batch);
555    let n_batches = batch.iter().max().map_or(0, |&m| m + 1);
556    let bits = default_state_bits(ncols, n_batches, proj_kn.nrows());
557    info!(
558        "adjusting batch biases within cell state ({} batches, {} state bits) ...",
559        n_batches, bits
560    );
561    *proj_kn = centre_batches_within_state(proj_kn, &batch, bits)?;
562    Ok(())
563}
564
565/// The mixed partition of [`RandProjOps::partition_columns_to_mixed_groups`]
566/// before it is assigned: codes, merged labels and batches of the columns.
567pub struct MixedPartition {
568    /// binary code of each column
569    pub codes: Vec<usize>,
570    /// merged group label of each column
571    pub labels: Vec<usize>,
572    pub num_groups: usize,
573    /// bits of the codes
574    pub bits: usize,
575    /// batch of each column, in `0..n_batches`
576    pub batch: Vec<usize>,
577    /// batches a group needs, capped by the number of batches
578    pub min_batches: usize,
579}
580
581/// Bin columns by the sign codes of `proj_kn` and merge bins holding fewer
582/// than `min_batches` batches up the code tree, for at most `merge_levels`
583/// levels.
584pub fn mixed_partition<T>(
585    proj_kn: &nalgebra::DMatrix<f32>,
586    num_features: Option<usize>,
587    batch_membership: &[T],
588    min_batches: usize,
589    merge_levels: usize,
590) -> anyhow::Result<MixedPartition>
591where
592    T: std::hash::Hash + Eq + Clone,
593{
594    let nn = proj_kn.ncols();
595    anyhow::ensure!(
596        batch_membership.len() == nn,
597        "batch membership size {} mismatches the number of columns {}",
598        batch_membership.len(),
599        nn
600    );
601    let batch = batch_indices(batch_membership);
602    let n_batches = batch.iter().max().map_or(0, |&m| m + 1);
603    let min_batches = min_batches.min(n_batches);
604    let bits = proj_kn
605        .nrows()
606        .min(num_features.unwrap_or(proj_kn.nrows()))
607        .min(nn);
608    let codes = binary_sort_columns(proj_kn, bits)?;
609    let labels = merge_poorly_mixed_bins(&codes, &batch, bits, merge_levels, min_batches);
610    let num_groups = labels
611        .iter()
612        .collect::<std::collections::HashSet<_>>()
613        .len();
614    info!(
615        "partitioned columns into {} groups ({} bits, bins with fewer than {} batches merged up to {} levels)",
616        num_groups, bits, min_batches, merge_levels
617    );
618    Ok(MixedPartition {
619        codes,
620        labels,
621        num_groups,
622        bits,
623        batch,
624        min_batches,
625    })
626}
627
628/// Binarize the projection matrix and assign columns to some groups
629///
630/// # Arguments
631/// * `proj_kn` - random projection matrix (feature x column/cell)
632/// * `kk` - number of features, one bit of the code each; fewer than the
633///   bits of a `usize`
634pub fn binary_sort_columns(
635    proj_kn: &nalgebra::DMatrix<f32>,
636    kk: usize,
637) -> anyhow::Result<Vec<usize>> {
638    anyhow::ensure!(
639        kk < usize::BITS as usize,
640        "{kk} sorting features do not fit a {}-bit binary code: use fewer than {}",
641        usize::BITS,
642        usize::BITS
643    );
644    // // standardization again to make sure that each coordinates is treated equally
645    // let proj_nk = proj_kn.transpose().scale_columns();
646    // let nn = proj_kn.ncols();
647    // // SVD to spread out the points
648    // let (mut q_nk, _, _) = proj_nk.rsvd(kk)?;
649    // q_nk.scale_columns_inplace();
650
651    // SVD to spread out the points
652    let nn = proj_kn.ncols();
653    let (_, _, mut q_nk) = proj_kn.rsvd(kk)?;
654    q_nk.scale_columns_inplace();
655
656    let mut binary_codes = DVector::<usize>::zeros(nn);
657    for k in 0..kk {
658        let binary_shift = |x: f32| -> usize {
659            if x > 0.0 {
660                1 << k
661            } else {
662                0
663            }
664        };
665        binary_codes += q_nk.column(k).map(binary_shift);
666    }
667
668    Ok(binary_codes.data.as_vec().clone())
669}
670
671/// maximum binary code (binary shifting)
672pub fn max_binary_code(kk: usize) -> usize {
673    (1 << kk) - 1
674}
675
676#[cfg(test)]
677mod repro_tests {
678    use super::*;
679    use crate::sparse_io::{create_sparse_from_triplets, SparseIoBackend};
680    use std::sync::Arc;
681
682    /// Small 8-gene × 12-cell `SparseIoVec` for exercising the projection.
683    fn tiny_data_vec(dir: &tempfile::TempDir) -> anyhow::Result<SparseIoVec> {
684        let path = dir.path().join("proj.zarr");
685        let path_str = path.to_str().unwrap();
686        let (d, n) = (8usize, 12usize);
687        // Deterministic sparse pattern (nonzero where (i*7 + j*3) % 5 < 3).
688        let mut triplets: Vec<(u64, u64, f32)> = Vec::new();
689        for i in 0..d {
690            for j in 0..n {
691                if (i * 7 + j * 3) % 5 < 3 {
692                    triplets.push((i as u64, j as u64, 1.0 + ((i + j) % 4) as f32));
693                }
694            }
695        }
696        let nnz = triplets.len();
697        let mut backend = create_sparse_from_triplets(
698            &triplets,
699            (d, n, nnz),
700            Some(path_str),
701            Some(&SparseIoBackend::Zarr),
702        )?;
703        let gene_names: Vec<Box<str>> = (0..d).map(|i| format!("g{i}").into()).collect();
704        let cell_names: Vec<Box<str>> = (0..n).map(|j| format!("c{j}").into()).collect();
705        backend.register_row_names_vec(&gene_names);
706        backend.register_column_names_vec(&cell_names);
707        let mut data_vec = SparseIoVec::new();
708        data_vec.push(Arc::from(backend), None)?;
709        Ok(data_vec)
710    }
711
712    /// The seeded projection must be byte-identical across runs for a fixed seed
713    /// — this is the reproducibility guarantee the bge pipeline relies on.
714    #[test]
715    fn seeded_projection_is_reproducible() -> anyhow::Result<()> {
716        let dir = tempfile::tempdir()?;
717        let data_vec = tiny_data_vec(&dir)?;
718        let a =
719            data_vec.project_columns_with_batch_correction_seeded::<usize>(4, None, None, 123)?;
720        let b =
721            data_vec.project_columns_with_batch_correction_seeded::<usize>(4, None, None, 123)?;
722        assert_eq!(a.basis, b.basis, "same seed → identical basis");
723        assert_eq!(a.proj, b.proj, "same seed → identical projection");
724        Ok(())
725    }
726
727    /// The non-seeded entry point must also be reproducible now (it pins
728    /// `DEFAULT_PROJECTION_SEED` internally rather than drawing fresh entropy).
729    #[test]
730    fn default_projection_is_reproducible() -> anyhow::Result<()> {
731        let dir = tempfile::tempdir()?;
732        let data_vec = tiny_data_vec(&dir)?;
733        let a = data_vec.project_columns(4, None)?;
734        let b = data_vec.project_columns(4, None)?;
735        assert_eq!(a.basis, b.basis);
736        assert_eq!(a.proj, b.proj);
737        Ok(())
738    }
739
740    /// Distinct seeds must yield distinct bases (the seed genuinely drives the
741    /// projection, so `--seed` is authoritative where callers pass it).
742    #[test]
743    fn distinct_seeds_diverge() -> anyhow::Result<()> {
744        let dir = tempfile::tempdir()?;
745        let data_vec = tiny_data_vec(&dir)?;
746        let a = data_vec.project_columns_with_batch_correction_seeded::<usize>(4, None, None, 1)?;
747        let b = data_vec.project_columns_with_batch_correction_seeded::<usize>(4, None, None, 2)?;
748        assert_ne!(a.basis, b.basis, "distinct seeds → distinct basis");
749        Ok(())
750    }
751
752    /// Two modalities over the same 12 cells.
753    fn tiny_stack(
754        dir_a: &tempfile::TempDir,
755        dir_b: &tempfile::TempDir,
756    ) -> anyhow::Result<SparseIoStack> {
757        let mut stack = SparseIoStack::new();
758        stack.push(tiny_data_vec(dir_a)?)?;
759        stack.push(tiny_data_vec(dir_b)?)?;
760        Ok(stack)
761    }
762
763    /// A stack's projection is its modalities' projections, each under its
764    /// own sub-seed, stacked on the feature axis.
765    #[test]
766    fn stacked_projection_concatenates_the_modalities() -> anyhow::Result<()> {
767        let (dir_a, dir_b) = (tempfile::tempdir()?, tempfile::tempdir()?);
768        let stack = tiny_stack(&dir_a, &dir_b)?;
769        let batch: Vec<usize> = (0..12).map(|j| j % 2).collect();
770        let weights: Vec<f32> = (0..16).map(|r| (r % 3) as f32).collect();
771
772        let plain =
773            stack.project_columns_with_batch_correction_seeded(4, None, Some(&batch[..]), 9)?;
774        let weighted =
775            stack.project_columns_weighted_seeded(4, None, Some(&batch[..]), &weights, 9)?;
776
777        for (out, weighted_rows) in [(&plain, false), (&weighted, true)] {
778            assert_eq!(out.basis.shape(), (16, 4));
779            assert_eq!(out.proj.shape(), (8, 12));
780            for (m, data_vec) in stack.stack.iter().enumerate() {
781                let sub_seed = mix_seed(9, m as u64);
782                let one = if weighted_rows {
783                    data_vec.project_columns_weighted_seeded(
784                        4,
785                        None,
786                        Some(&batch[..]),
787                        &weights[m * 8..(m + 1) * 8],
788                        sub_seed,
789                    )?
790                } else {
791                    data_vec.project_columns_with_batch_correction_seeded(
792                        4,
793                        None,
794                        Some(&batch[..]),
795                        sub_seed,
796                    )?
797                };
798                assert_eq!(out.basis.rows(m * 8, 8), one.basis);
799                assert_eq!(out.proj.rows(m * 4, 4), one.proj);
800            }
801        }
802        Ok(())
803    }
804
805    /// The stack's projections under `batch`, plain and row-weighted.
806    fn stack_projections_with(
807        stack: &SparseIoStack,
808        batch: Option<&[usize]>,
809    ) -> anyhow::Result<[nalgebra::DMatrix<f32>; 2]> {
810        let weights = vec![1.0f32; 16];
811        Ok([
812            stack
813                .project_columns_with_batch_correction_seeded(4, None, batch, 9)?
814                .proj,
815            stack
816                .project_columns_weighted_seeded(4, None, batch, &weights, 9)?
817                .proj,
818        ])
819    }
820
821    /// A batch membership that does not cover every column of a stack
822    /// leaves the projection without a batch adjustment.
823    #[test]
824    fn stack_ignores_a_batch_membership_that_is_too_short() -> anyhow::Result<()> {
825        let (dir_a, dir_b) = (tempfile::tempdir()?, tempfile::tempdir()?);
826        let stack = tiny_stack(&dir_a, &dir_b)?;
827        let none = stack_projections_with(&stack, None)?;
828
829        let fitting: Vec<usize> = (0..12).map(|j| j % 2).collect();
830        let corrected = stack_projections_with(&stack, Some(&fitting))?;
831        assert_ne!(corrected, none, "the adjustment changes the fixture");
832
833        assert_eq!(stack_projections_with(&stack, Some(&fitting[..11]))?, none);
834        Ok(())
835    }
836
837    /// A batch membership longer than a stack has columns adjusts with its
838    /// first entries, one per column; the rest are not read.
839    #[test]
840    fn stack_truncates_a_batch_membership_that_is_too_long() -> anyhow::Result<()> {
841        let (dir_a, dir_b) = (tempfile::tempdir()?, tempfile::tempdir()?);
842        let stack = tiny_stack(&dir_a, &dir_b)?;
843        let none = stack_projections_with(&stack, None)?;
844
845        let fitting: Vec<usize> = (0..12).map(|j| j % 2).collect();
846        let corrected = stack_projections_with(&stack, Some(&fitting))?;
847        assert_ne!(corrected, none, "the adjustment changes the fixture");
848
849        // The surplus entry names a batch of its own.
850        let mut too_long = fitting.clone();
851        too_long.push(7);
852        assert_eq!(stack_projections_with(&stack, Some(&too_long))?, corrected);
853        Ok(())
854    }
855
856    /// A binary code holds one bit per sorting feature, so as many features
857    /// as a `usize` has bits are refused rather than wrapped.
858    #[test]
859    fn more_sorting_features_than_code_bits_are_refused() {
860        let bits = usize::BITS as usize;
861        let proj = nalgebra::DMatrix::<f32>::from_fn(bits, bits, |i, j| {
862            ((i * 31 + j * 17) % 7) as f32 - 3.0
863        });
864        assert!(binary_sort_columns(&proj, bits).is_err());
865        assert!(binary_sort_columns(&proj, 6).is_ok());
866    }
867}