Skip to main content

bixverse_core/core/
synthetic_data.rs

1//! Synthetic bulk RNAseq data with configurable co-expression topology plus
2//! Splatter-style sequencing-depth sparsification.
3//!
4//! Two things this module is meant to stress-test: co-expression module
5//! detection (WGCNA-like TOM plus downstream clustering) and how well those
6//! methods survive scRNA-seq-style dropout. On top of that, four generator
7//! variants make the same synthetic layout usable as a fair benchmark for
8//! NMF, ICA and dictionary-learning methods (DGRDL) — see
9//! [`BulkDataGenerator`] for the trade-offs.
10//!
11//! The counts come from a Gamma-Poisson (NB) mixture around a per-gene mean.
12//! When modules are on, a per-sample latent factor is drawn per module and
13//! each module gene loads on it (linearly on the log scale) plus per-gene
14//! noise. The variant chooses which factor and loading distributions are
15//! drawn from, which controls whether the resulting structure has hubs, a
16//! non-negative activity matrix (NMF-friendly), or non-Gaussian sources
17//! (ICA-friendly).
18//!
19//! The sparsification path is a Splatter-style multinomial downsample: a
20//! per-sample capture efficiency `s_j ~ LogNormal(0, sigma_s)` sets a target
21//! library size, and each gene's count is binomially thinned to hit it. This
22//! replaces the previous logistic and power-decay dropout functions, which
23//! had magic clamps and no biological anchor.
24//!
25//! ### References
26//!
27//! * Zhang B, Horvath S. A general framework for weighted gene co-expression
28//!   network analysis. Stat Appl Genet Mol Biol, 2005.
29//! * Khanin R, Wit E. How scale-free are biological networks? J Comput Biol,
30//!   2006. (Caveat on the strict scale-free claim.)
31//! * Zappia L, Phipson B, Oshlack A. Splatter: simulation of single-cell RNA
32//!   sequencing data. Genome Biology, 2017.
33
34use faer::{Mat, MatRef};
35use rand::prelude::*;
36use rand_distr::{Beta, Binomial, Distribution, Gamma, LogNormal, Normal, Poisson};
37use rayon::prelude::*;
38
39use crate::errors::CoreErrors;
40use crate::prelude::BixverseFloat;
41
42////////////////////
43// Default consts //
44////////////////////
45
46/// Shape of the Gamma prior on per-gene mean expression. Combined with the
47/// scale below, gives a mean of ~50 counts and a heavy right tail.
48const DEFAULT_MEAN_EXP_SHAPE: f64 = 5.0;
49
50/// Scale of the Gamma prior on per-gene mean expression.
51const DEFAULT_MEAN_EXP_SCALE: f64 = 10.0;
52
53/// Intercept of the NB dispersion trend `disp = 1 / (a + b * mean)`.
54const DEFAULT_DISP_INTERCEPT: f64 = 0.2;
55
56/// Slope of the NB dispersion trend.
57const DEFAULT_DISP_SLOPE: f64 = 0.3;
58
59/// Per-gene per-sample noise std on the latent log-signal.
60const DEFAULT_NOISE_STD: f64 = 0.3;
61
62/// Std of the Normal latent factor. Kept below 1 so even hub genes stay in a
63/// workable log-signal range.
64const DEFAULT_NORMAL_FACTOR_STD: f64 = 0.3;
65
66/// Shape of the Gamma latent factor used for the non-negative variant. With
67/// scale = 0.3, mean = 0.6 and mode = 0.3 — non-negative, unimodal, mild tail.
68const DEFAULT_GAMMA_FACTOR_SHAPE: f64 = 2.0;
69
70/// Scale of the Gamma latent factor for the non-negative variant.
71const DEFAULT_GAMMA_FACTOR_SCALE: f64 = 0.3;
72
73/// Scale of the Laplace latent factor for the non-Gaussian variant.
74/// `b = 0.3` gives std ≈ 0.42, comparable to the Normal factor default.
75const DEFAULT_LAPLACE_FACTOR_SCALE: f64 = 0.3;
76
77/// LogNormal location for hub-style loadings. `mu = 0` gives median loading 1.
78const DEFAULT_LOADING_LN_MU: f64 = 0.0;
79
80/// LogNormal scale for hub-style loadings. `sigma = 0.7` gives q99 loading of
81/// about 5, so a hub's log-signal std is `~5 * factor_std ≈ 1.5` — clean exp
82/// range, tail heavy enough to drive a hub-degree gradient.
83const DEFAULT_LOADING_LN_SIGMA: f64 = 0.7;
84
85/// Fraction of module genes flagged as hubs (top X% by loading).
86const DEFAULT_HUB_PERCENTILE: f64 = 0.1;
87
88/// Reference library size for the sequencing-depth dropout.
89const DEFAULT_TARGET_LIB_SIZE: f64 = 20_000.0;
90
91/// LogNormal scale for per-sample size factors (Splatter default region).
92const DEFAULT_CAPTURE_SIGMA: f64 = 0.5;
93
94/////////////////////
95// Generator enums //
96/////////////////////
97
98/// Shared config for LogNormal-loading variants (all except
99/// [`BulkDataGenerator::Modular`]).
100#[derive(Clone, Debug)]
101pub struct LoadingLogNormal<T: BixverseFloat> {
102    /// LogNormal location parameter for the loading distribution.
103    pub mu: T,
104    /// LogNormal scale parameter for the loading distribution.
105    pub sigma: T,
106    /// Top-fraction of module genes flagged as hubs by loading rank.
107    pub hub_percentile: T,
108}
109
110impl<T: BixverseFloat> Default for LoadingLogNormal<T> {
111    fn default() -> Self {
112        Self {
113            mu: T::from_f64(DEFAULT_LOADING_LN_MU).unwrap(),
114            sigma: T::from_f64(DEFAULT_LOADING_LN_SIGMA).unwrap(),
115            hub_percentile: T::from_f64(DEFAULT_HUB_PERCENTILE).unwrap(),
116        }
117    }
118}
119
120/// Config for [`BulkDataGenerator::Modular`].
121///
122/// Uses a Beta(5, 2) loading distribution (mean ~0.71, narrow) and a Normal
123/// factor. No hubs.
124#[derive(Clone, Debug)]
125pub struct ModularConfig<T: BixverseFloat> {
126    /// Std of the Normal factor drawn per sample.
127    pub factor_std: T,
128}
129
130impl<T: BixverseFloat> Default for ModularConfig<T> {
131    fn default() -> Self {
132        Self {
133            factor_std: T::from_f64(DEFAULT_NORMAL_FACTOR_STD).unwrap(),
134        }
135    }
136}
137
138/// Config for [`BulkDataGenerator::HubModular`].
139///
140/// LogNormal loadings + Normal factor. Heavy-tailed loading gives a hub /
141/// non-hub degree gradient after correlation thresholding — useful for
142/// WGCNA-style benchmarks.
143#[derive(Clone, Debug)]
144pub struct HubModularConfig<T: BixverseFloat> {
145    /// Std of the Normal factor drawn per sample.
146    pub factor_std: T,
147    /// LogNormal loading config.
148    pub loading: LoadingLogNormal<T>,
149}
150
151impl<T: BixverseFloat> Default for HubModularConfig<T> {
152    fn default() -> Self {
153        Self {
154            factor_std: T::from_f64(DEFAULT_NORMAL_FACTOR_STD).unwrap(),
155            loading: LoadingLogNormal::default(),
156        }
157    }
158}
159
160/// Config for [`BulkDataGenerator::NonNegativeFactor`].
161///
162/// ### Fields
163///
164/// LogNormal loadings + Gamma factor. The Gamma factor is non-negative, so
165/// the ground-truth activity matrix `H` is directly comparable to what NMF
166/// recovers (NMF constrains `H >= 0`).
167#[derive(Clone, Debug)]
168pub struct NonNegativeFactorConfig<T: BixverseFloat> {
169    /// Shape of the Gamma factor drawn per sample.
170    pub factor_shape: T,
171    /// Scale of the Gamma factor drawn per sample.
172    pub factor_scale: T,
173    /// LogNormal loading config.
174    pub loading: LoadingLogNormal<T>,
175}
176
177impl<T: BixverseFloat> Default for NonNegativeFactorConfig<T> {
178    fn default() -> Self {
179        Self {
180            factor_shape: T::from_f64(DEFAULT_GAMMA_FACTOR_SHAPE).unwrap(),
181            factor_scale: T::from_f64(DEFAULT_GAMMA_FACTOR_SCALE).unwrap(),
182            loading: LoadingLogNormal::default(),
183        }
184    }
185}
186
187/// Config for [`BulkDataGenerator::NonGaussianFactor`].
188///
189/// LogNormal loadings + Laplace factor. Laplace is symmetric heavy-tailed -
190/// non-Gaussian by construction - which is the identifiability condition ICA
191/// needs on the source signals.
192#[derive(Clone, Debug)]
193pub struct NonGaussianFactorConfig<T: BixverseFloat> {
194    /// Laplace scale (`b`) of the factor. Std of Laplace(0, b) is
195    /// `b * sqrt(2)`.
196    pub factor_scale: T,
197    /// LogNormal loading config.
198    pub loading: LoadingLogNormal<T>,
199}
200
201impl<T: BixverseFloat> Default for NonGaussianFactorConfig<T> {
202    fn default() -> Self {
203        Self {
204            factor_scale: T::from_f64(DEFAULT_LAPLACE_FACTOR_SCALE).unwrap(),
205            loading: LoadingLogNormal::default(),
206        }
207    }
208}
209
210/// Topology + factor / loading distribution to inject into the module
211/// structure.
212#[derive(Clone, Debug)]
213pub enum BulkDataGenerator<T: BixverseFloat> {
214    /// Beta(5, 2) loadings, Normal factor. Homogeneous within-module
215    /// correlation, no hubs.
216    Modular(ModularConfig<T>),
217    /// LogNormal loadings, Normal factor. Heavy-tailed loading distribution
218    /// makes a few module genes act as hubs. WGCNA-style benchmark default.
219    HubModular(HubModularConfig<T>),
220    /// LogNormal loadings, Gamma (non-negative) factor. NMF-friendly ground
221    /// truth: the activity matrix `H` is non-negative by construction.
222    NonNegativeFactor(NonNegativeFactorConfig<T>),
223    /// LogNormal loadings, Laplace (non-Gaussian) factor. ICA-friendly ground
224    /// truth: source signals are non-Gaussian, satisfying ICA identifiability.
225    NonGaussianFactor(NonGaussianFactorConfig<T>),
226}
227
228impl<T: BixverseFloat> Default for BulkDataGenerator<T> {
229    fn default() -> Self {
230        BulkDataGenerator::HubModular(HubModularConfig::default())
231    }
232}
233
234impl<T: BixverseFloat> BulkDataGenerator<T> {
235    /// Access the shared LogNormal loading config, if the variant has one.
236    ///
237    /// ### Returns
238    ///
239    /// [`BulkDataGenerator::Modular`] returns `None` (Beta loadings).
240    fn loading(&self) -> Option<&LoadingLogNormal<T>> {
241        match self {
242            BulkDataGenerator::Modular(_) => None,
243            BulkDataGenerator::HubModular(c) => Some(&c.loading),
244            BulkDataGenerator::NonNegativeFactor(c) => Some(&c.loading),
245            BulkDataGenerator::NonGaussianFactor(c) => Some(&c.loading),
246        }
247    }
248
249    /// Human-readable label for logs and error messages.
250    ///
251    /// ### Returns
252    ///
253    /// The respective String.
254    fn label(&self) -> &'static str {
255        match self {
256            BulkDataGenerator::Modular(_) => "modular",
257            BulkDataGenerator::HubModular(_) => "hub_modular",
258            BulkDataGenerator::NonNegativeFactor(_) => "non_negative_factor",
259            BulkDataGenerator::NonGaussianFactor(_) => "non_gaussian_factor",
260        }
261    }
262}
263
264/// Parse a generator label into a [`BulkDataGenerator`] with default config.
265///
266/// ### Params
267///
268/// * `s` - String to parse; matched case-insensitively.
269///
270/// ### Returns
271///
272/// `Some(BulkDataGenerator)` on match with default config for that variant,
273/// `None` otherwise. Use the enum constructors directly if you need to
274/// override the defaults.
275pub fn parse_bulk_data_generator<T: BixverseFloat>(s: &str) -> Option<BulkDataGenerator<T>> {
276    match s.to_lowercase().as_str() {
277        "modular" => Some(BulkDataGenerator::Modular(ModularConfig::default())),
278        "hub_modular" | "hubmodular" | "hub-modular" => {
279            Some(BulkDataGenerator::HubModular(HubModularConfig::default()))
280        }
281        "non_negative_factor" | "nonnegative" | "nmf" => Some(
282            BulkDataGenerator::NonNegativeFactor(NonNegativeFactorConfig::default()),
283        ),
284        "non_gaussian_factor" | "nongaussian" | "ica" => Some(
285            BulkDataGenerator::NonGaussianFactor(NonGaussianFactorConfig::default()),
286        ),
287        _ => None,
288    }
289}
290
291/// Dropout / sparsification strategy.
292#[derive(Clone, Copy, Debug, PartialEq, Eq)]
293pub enum DropoutStrategy {
294    /// Splatter-style: per-sample size factor `s_j ~ LogNormal(0, sigma_s)`,
295    /// target library `L_j = target * s_j`, per-gene binomial thinning to hit
296    /// the target.
297    SequencingDepth,
298}
299
300/// Parse the dropout strategy from a string (for R-side dispatch).
301///
302/// ### Params
303///
304/// * `s` - String to parse; matched case-insensitively.
305///
306/// ### Returns
307///
308/// `Some(DropoutStrategy)` on match, `None` otherwise.
309pub fn parse_dropout_strategy(s: &str) -> Option<DropoutStrategy> {
310    match s.to_lowercase().as_str() {
311        "seq_depth" | "sequencing_depth" | "sequencingdepth" => {
312            Some(DropoutStrategy::SequencingDepth)
313        }
314        _ => None,
315    }
316}
317
318////////////
319// Params //
320////////////
321
322/// Parameters for [`generate_bulk_rnaseq`].
323///
324/// The generator variant lives inside [`SyntheticRnaSeqParams::generator`];
325/// everything else, dimensions, module layout, the NB count model, noise, is
326/// shared across variants.
327#[derive(Clone, Debug)]
328pub struct SyntheticRnaSeqParams<T: BixverseFloat> {
329    /// Number of samples (columns) in the count matrix.
330    pub num_samples: usize,
331    /// Number of genes (rows) in the count matrix.
332    pub num_genes: usize,
333    /// Base seed. Per-gene RNGs are derived by wrapping-add on this.
334    pub seed: u64,
335    /// Sizes of the co-expression modules. Empty means no modules.
336    /// `sum(module_sizes) <= num_genes` is enforced.
337    pub module_sizes: Vec<usize>,
338    /// Which topology / distribution family to inject. See
339    /// [`BulkDataGenerator`].
340    pub generator: BulkDataGenerator<T>,
341    /// Gamma shape for per-gene mean expression.
342    pub mean_exp_gamma_shape: T,
343    /// Gamma scale for per-gene mean expression.
344    pub mean_exp_gamma_scale: T,
345    /// Intercept of the NB dispersion trend `disp = 1 / (a + b * mean)`.
346    pub disp_intercept: T,
347    /// Slope of the NB dispersion trend.
348    pub disp_slope: T,
349    /// Per-gene per-sample noise std on the latent log-signal. Smaller =
350    /// tighter tracking of the module factor.
351    pub noise_std: T,
352}
353
354impl<T: BixverseFloat> Default for SyntheticRnaSeqParams<T> {
355    fn default() -> Self {
356        Self {
357            num_samples: 100,
358            num_genes: 2_000,
359            seed: 42,
360            module_sizes: vec![300, 250, 200, 300, 500],
361            generator: BulkDataGenerator::default(),
362            mean_exp_gamma_shape: T::from_f64(DEFAULT_MEAN_EXP_SHAPE).unwrap(),
363            mean_exp_gamma_scale: T::from_f64(DEFAULT_MEAN_EXP_SCALE).unwrap(),
364            disp_intercept: T::from_f64(DEFAULT_DISP_INTERCEPT).unwrap(),
365            disp_slope: T::from_f64(DEFAULT_DISP_SLOPE).unwrap(),
366            noise_std: T::from_f64(DEFAULT_NOISE_STD).unwrap(),
367        }
368    }
369}
370
371impl<T: BixverseFloat> SyntheticRnaSeqParams<T> {
372    /// Sanity-check the parameters.
373    ///
374    /// ### Returns
375    ///
376    /// `Ok(())` on success, [`CoreErrors::InvalidArgument`] on failure.
377    pub fn validate(&self) -> Result<(), CoreErrors> {
378        if self.num_samples == 0 {
379            return Err(CoreErrors::InvalidArgument(
380                "num_samples must be > 0".into(),
381            ));
382        }
383        if self.num_genes == 0 {
384            return Err(CoreErrors::InvalidArgument("num_genes must be > 0".into()));
385        }
386        let total: usize = self.module_sizes.iter().sum();
387        if total > self.num_genes {
388            return Err(CoreErrors::InvalidArgument(format!(
389                "sum(module_sizes)={total} exceeds num_genes={}",
390                self.num_genes
391            )));
392        }
393        if self.mean_exp_gamma_shape.to_f64().unwrap() <= 0.0
394            || self.mean_exp_gamma_scale.to_f64().unwrap() <= 0.0
395        {
396            return Err(CoreErrors::InvalidArgument(
397                "mean_exp gamma shape/scale must be > 0".into(),
398            ));
399        }
400        if self.noise_std.to_f64().unwrap() < 0.0 {
401            return Err(CoreErrors::InvalidArgument("noise_std must be >= 0".into()));
402        }
403        // per-variant validation
404        match &self.generator {
405            BulkDataGenerator::Modular(c) => {
406                if c.factor_std.to_f64().unwrap() <= 0.0 {
407                    return Err(CoreErrors::InvalidArgument(
408                        "modular: factor_std must be > 0".into(),
409                    ));
410                }
411            }
412            BulkDataGenerator::HubModular(c) => {
413                if c.factor_std.to_f64().unwrap() <= 0.0 {
414                    return Err(CoreErrors::InvalidArgument(
415                        "hub_modular: factor_std must be > 0".into(),
416                    ));
417                }
418                validate_loading(&c.loading, "hub_modular")?;
419            }
420            BulkDataGenerator::NonNegativeFactor(c) => {
421                if c.factor_shape.to_f64().unwrap() <= 0.0
422                    || c.factor_scale.to_f64().unwrap() <= 0.0
423                {
424                    return Err(CoreErrors::InvalidArgument(
425                        "non_negative_factor: factor_shape/factor_scale must be > 0".into(),
426                    ));
427                }
428                validate_loading(&c.loading, "non_negative_factor")?;
429            }
430            BulkDataGenerator::NonGaussianFactor(c) => {
431                if c.factor_scale.to_f64().unwrap() <= 0.0 {
432                    return Err(CoreErrors::InvalidArgument(
433                        "non_gaussian_factor: factor_scale must be > 0".into(),
434                    ));
435                }
436                validate_loading(&c.loading, "non_gaussian_factor")?;
437            }
438        }
439        Ok(())
440    }
441}
442
443/// Validate the LogNormal loading sub-config.
444fn validate_loading<T: BixverseFloat>(
445    l: &LoadingLogNormal<T>,
446    label: &str,
447) -> Result<(), CoreErrors> {
448    if l.sigma.to_f64().unwrap() <= 0.0 {
449        return Err(CoreErrors::InvalidArgument(format!(
450            "{label}: loading.sigma must be > 0"
451        )));
452    }
453    let hub_p = l.hub_percentile.to_f64().unwrap();
454    if !(hub_p > 0.0 && hub_p <= 1.0) {
455        return Err(CoreErrors::InvalidArgument(format!(
456            "{label}: loading.hub_percentile must be in (0, 1]"
457        )));
458    }
459    Ok(())
460}
461
462/// Parameters for [`apply_dropout`].
463///
464/// ### Fields
465///
466/// The [`Default`] impl gives Splatter-style sequencing-depth dropout with a
467/// reference library size of 20,000 and per-sample size factor std of 0.5.
468#[derive(Clone, Debug)]
469pub struct SparsityParams<T: BixverseFloat> {
470    /// Which dropout strategy to run.
471    pub strategy: DropoutStrategy,
472    /// Reference library size. Actual per-sample target is
473    /// `target_library_size * s_j` where `s_j ~ LogNormal(0, sigma_s)`.
474    pub target_library_size: T,
475    /// Std of the LogNormal size-factor distribution.
476    pub capture_efficiency_sigma: T,
477    /// Seed for the dropout RNG.
478    pub seed: u64,
479}
480
481impl<T: BixverseFloat> Default for SparsityParams<T> {
482    fn default() -> Self {
483        Self {
484            strategy: DropoutStrategy::SequencingDepth,
485            target_library_size: T::from_f64(DEFAULT_TARGET_LIB_SIZE).unwrap(),
486            capture_efficiency_sigma: T::from_f64(DEFAULT_CAPTURE_SIGMA).unwrap(),
487            seed: 42,
488        }
489    }
490}
491
492impl<T: BixverseFloat> SparsityParams<T> {
493    /// Sanity-check the parameters.
494    ///
495    /// ### Returns
496    ///
497    /// `Ok(())` on success, [`CoreErrors::InvalidArgument`] on failure.
498    pub fn validate(&self) -> Result<(), CoreErrors> {
499        if self.target_library_size.to_f64().unwrap() <= 0.0 {
500            return Err(CoreErrors::InvalidArgument(
501                "target_library_size must be > 0".into(),
502            ));
503        }
504        if self.capture_efficiency_sigma.to_f64().unwrap() <= 0.0 {
505            return Err(CoreErrors::InvalidArgument(
506                "capture_efficiency_sigma must be > 0".into(),
507            ));
508        }
509        Ok(())
510    }
511}
512
513/////////////
514// Results //
515/////////////
516
517/// Output of [`generate_bulk_rnaseq`].
518///
519/// ### Fields
520///
521/// Beyond the count matrix, the ground-truth latent factors and per-gene
522/// loadings are returned so downstream tests can compute analytical
523/// expectations (e.g. correlate a recovered module eigengene against the
524/// true factor) without materialising a full gene x gene correlation matrix.
525#[derive(Clone, Debug)]
526pub struct SyntheticRnaSeqData<T: BixverseFloat> {
527    /// Count matrix, shape `(num_genes, num_samples)`.
528    pub count_matrix: Mat<T>,
529    /// Per-gene module assignment. `0` = background, `1..=K` = module id.
530    pub gene_modules: Vec<usize>,
531    /// Indices of the genes flagged as hubs. Empty under
532    /// [`BulkDataGenerator::Modular`].
533    pub module_hubs: Vec<usize>,
534    /// Per-gene loading on its module's latent factor. `0` for background.
535    pub loadings: Vec<T>,
536    /// Latent factor matrix, shape `(num_modules, num_samples)`. Empty when
537    /// no modules were requested.
538    pub module_factors: Mat<T>,
539    /// Noise std used for the latent log-signal, echoed back for analytical
540    /// ground truth.
541    pub sigma_noise: T,
542}
543
544////////////////////
545// Bulk generator //
546////////////////////
547
548/// Generate synthetic bulk RNAseq counts with the requested co-expression
549/// topology.
550///
551/// Per-gene mean expression comes from a Gamma prior. When `module_sizes` is
552/// non-empty, each module gets one latent factor per sample and each module
553/// gene loads on it (linearly on the log scale) plus per-gene noise. The
554/// [`BulkDataGenerator`] variant controls which factor and loading
555/// distributions are drawn from. Counts are Gamma-Poisson (NB) sampled with
556/// dispersion trending `1 / (a + b * mean)`.
557///
558/// ### Params
559///
560/// * `params` - See [`SyntheticRnaSeqParams`].
561///
562/// ### Returns
563///
564/// [`SyntheticRnaSeqData`] on success, [`CoreErrors::InvalidArgument`]
565/// on invalid params. The count matrix has shape `(num_genes, num_samples)`.
566///
567/// ### References
568///
569/// Zhang & Horvath 2005 (WGCNA), Khanin & Wit 2006 (scale-free caveat).
570pub fn generate_bulk_rnaseq<T>(
571    params: &SyntheticRnaSeqParams<T>,
572) -> Result<SyntheticRnaSeqData<T>, CoreErrors>
573where
574    T: BixverseFloat,
575{
576    params.validate()?;
577
578    let num_samples = params.num_samples;
579    let num_genes = params.num_genes;
580    let seed = params.seed;
581    let sigma_noise_f64 = params.noise_std.to_f64().unwrap();
582
583    let mut rng = StdRng::seed_from_u64(seed);
584
585    // ---- gene-level statistics --------------------------------------------
586    let gamma_mean = Gamma::new(
587        params.mean_exp_gamma_shape.to_f64().unwrap(),
588        params.mean_exp_gamma_scale.to_f64().unwrap(),
589    )
590    .map_err(|e| CoreErrors::InvalidArgument(format!("gamma(mean) init: {e}")))?;
591    let mean_exp: Vec<f64> = (0..num_genes)
592        .map(|_| gamma_mean.sample(&mut rng))
593        .collect();
594    let disp_intercept = params.disp_intercept.to_f64().unwrap();
595    let disp_slope = params.disp_slope.to_f64().unwrap();
596    let dispersion: Vec<f64> = mean_exp
597        .iter()
598        .map(|&m| 1.0 / (disp_intercept + disp_slope * m))
599        .collect();
600
601    // ---- module assignment ------------------------------------------------
602    let mut gene_modules = vec![0usize; num_genes];
603    let mut gene_idx = 0usize;
604    for (m_id, &size) in params.module_sizes.iter().enumerate() {
605        for _ in 0..size {
606            if gene_idx >= num_genes {
607                break;
608            }
609            gene_modules[gene_idx] = m_id + 1;
610            gene_idx += 1;
611        }
612    }
613    let num_modules = params.module_sizes.len();
614
615    // ---- module latent factors --------------------------------------------
616    // faer Mat is column-major; storing as (n_modules, n_samples) means each
617    // sample is a column. We keep a Vec<Vec<f64>> for cheap per-thread reads
618    // in the parallel gene loop, then copy into a Mat<T> at the end.
619    let module_factor_vecs = draw_factors(num_modules, num_samples, &params.generator, &mut rng)
620        .map_err(|e| CoreErrors::InvalidArgument(format!("factor draw: {e}")))?;
621
622    // ---- per-gene loadings ------------------------------------------------
623    let loadings_f64 = draw_loadings(&gene_modules, num_genes, &params.generator, &mut rng)?;
624
625    // ---- hub identification -----------------------------------------------
626    let module_hubs = match &params.generator {
627        BulkDataGenerator::Modular(_) => Vec::new(),
628        _ => {
629            let hub_p = params
630                .generator
631                .loading()
632                .map(|l| l.hub_percentile.to_f64().unwrap())
633                .unwrap_or(DEFAULT_HUB_PERCENTILE);
634            identify_hubs(&gene_modules, &loadings_f64, hub_p)
635        }
636    };
637
638    // ---- per-gene NB sampling ---------------------------------------------
639    let inv_num_samples = 1.0 / num_samples as f64;
640    let has_modules = num_modules > 0;
641
642    let gene_data: Vec<Vec<f64>> = (0..num_genes)
643        .into_par_iter()
644        .map(|i| {
645            let mut local_rng = StdRng::seed_from_u64(seed.wrapping_add((i as u64) * 1000 + 1));
646            let mut counts = Vec::with_capacity(num_samples);
647
648            let module_id = gene_modules[i];
649            let in_module = has_modules && module_id > 0;
650
651            if in_module {
652                let m = module_id - 1;
653                let loading = loadings_f64[i];
654                let factor = &module_factor_vecs[m];
655
656                // eps ~ Normal(0, sigma_N); latent log-signal z = loading * f + eps
657                let noise = if sigma_noise_f64 > 0.0 {
658                    Some(Normal::new(0.0, sigma_noise_f64).unwrap())
659                } else {
660                    None
661                };
662                let mut latent_exp = Vec::with_capacity(num_samples);
663                let mut sum_signal = 0.0;
664                for j in 0..num_samples {
665                    let eps = match noise {
666                        Some(n) => n.sample(&mut local_rng),
667                        None => 0.0,
668                    };
669                    let z = loading * factor[j] + eps;
670                    let s = z.exp();
671                    latent_exp.push(s);
672                    sum_signal += s;
673                }
674                let mean_signal = sum_signal * inv_num_samples;
675                let scale = if mean_signal > 0.0 {
676                    mean_exp[i] / mean_signal
677                } else {
678                    1.0
679                };
680
681                let r = 1.0 / dispersion[i];
682                for j in 0..num_samples {
683                    let mu_ij = latent_exp[j] * scale;
684                    let count = nb_sample(r, mu_ij, &mut local_rng);
685                    counts.push(count);
686                }
687            } else {
688                // background: pure NB around mean_exp[i]
689                let r = 1.0 / dispersion[i];
690                for _ in 0..num_samples {
691                    let count = nb_sample(r, mean_exp[i], &mut local_rng);
692                    counts.push(count);
693                }
694            }
695
696            counts
697        })
698        .collect();
699
700    // ---- write into faer Mats --------------------------------------------
701    let mut count_matrix: Mat<T> = Mat::zeros(num_genes, num_samples);
702    for (i, gene_counts) in gene_data.into_iter().enumerate() {
703        for (j, c) in gene_counts.into_iter().enumerate() {
704            count_matrix[(i, j)] = T::from_f64(c).unwrap();
705        }
706    }
707
708    let mut module_factors: Mat<T> = Mat::zeros(num_modules, num_samples);
709    for (m, row) in module_factor_vecs.iter().enumerate() {
710        for (j, &v) in row.iter().enumerate() {
711            module_factors[(m, j)] = T::from_f64(v).unwrap();
712        }
713    }
714
715    let loadings: Vec<T> = loadings_f64
716        .into_iter()
717        .map(|v| T::from_f64(v).unwrap())
718        .collect();
719
720    let _label = params.generator.label(); // reserved for future logging
721
722    Ok(SyntheticRnaSeqData {
723        count_matrix,
724        gene_modules,
725        module_hubs,
726        loadings,
727        module_factors,
728        sigma_noise: params.noise_std,
729    })
730}
731
732// ---- helpers ----------------------------------------------------------
733
734/// Draw the per-module latent factors for every sample.
735///
736/// One row per module, one column per sample. The factor distribution is
737/// picked from `generator`. For [`BulkDataGenerator::NonGaussianFactor`], the
738/// factor is drawn from Laplace(0, b) via inverse-CDF sampling — rand_distr
739/// 0.5 has no Laplace distribution so we synthesise it inline.
740///
741/// ### Params
742///
743/// * `num_modules` - Number of modules (rows).
744/// * `num_samples` - Samples per module (columns).
745/// * `generator` - Which factor distribution to draw from.
746/// * `rng` - Seeded RNG.
747///
748/// ### Returns
749///
750/// One inner `Vec<f64>` per module of length `num_samples`, or a distribution
751/// construction error propagated as a string.
752fn draw_factors<T: BixverseFloat>(
753    num_modules: usize,
754    num_samples: usize,
755    generator: &BulkDataGenerator<T>,
756    rng: &mut StdRng,
757) -> Result<Vec<Vec<f64>>, String> {
758    let mut out = Vec::with_capacity(num_modules);
759    match generator {
760        BulkDataGenerator::Modular(c) => {
761            let normal = Normal::new(0.0, c.factor_std.to_f64().unwrap())
762                .map_err(|e| format!("normal init: {e}"))?;
763            for _ in 0..num_modules {
764                out.push((0..num_samples).map(|_| normal.sample(rng)).collect());
765            }
766        }
767        BulkDataGenerator::HubModular(c) => {
768            let normal = Normal::new(0.0, c.factor_std.to_f64().unwrap())
769                .map_err(|e| format!("normal init: {e}"))?;
770            for _ in 0..num_modules {
771                out.push((0..num_samples).map(|_| normal.sample(rng)).collect());
772            }
773        }
774        BulkDataGenerator::NonNegativeFactor(c) => {
775            let gamma = Gamma::new(
776                c.factor_shape.to_f64().unwrap(),
777                c.factor_scale.to_f64().unwrap(),
778            )
779            .map_err(|e| format!("gamma(factor) init: {e}"))?;
780            for _ in 0..num_modules {
781                out.push((0..num_samples).map(|_| gamma.sample(rng)).collect());
782            }
783        }
784        BulkDataGenerator::NonGaussianFactor(c) => {
785            let b = c.factor_scale.to_f64().unwrap();
786            for _ in 0..num_modules {
787                out.push(
788                    (0..num_samples)
789                        .map(|_| laplace_sample(0.0, b, rng))
790                        .collect(),
791                );
792            }
793        }
794    }
795    Ok(out)
796}
797
798/// Sample one draw from Laplace(mu, b) via inverse-CDF. Symmetric heavy-tailed,
799/// std = `b * sqrt(2)`.
800///
801/// ### Params
802///
803/// * `mu` - Location.
804/// * `b` - Scale.
805/// * `rng` - RNG.
806///
807/// ### Returns
808///
809/// A single f64 sample.
810#[inline]
811fn laplace_sample(mu: f64, b: f64, rng: &mut StdRng) -> f64 {
812    // U in (-0.5, 0.5). Guard the endpoints so ln(0) can't occur.
813    let mut u: f64 = rng.random::<f64>() - 0.5;
814    if u == -0.5 {
815        u = -0.5 + f64::EPSILON;
816    }
817    let sign = if u >= 0.0 { -1.0 } else { 1.0 };
818    mu + sign * b * (1.0 - 2.0 * u.abs()).ln()
819}
820
821/// Draw per-gene loadings under the requested generator variant. Zero for
822/// background genes.
823///
824/// ### Params
825///
826/// * `gene_modules` - Length `num_genes`; `0` means background.
827/// * `num_genes` - Total number of genes.
828/// * `generator` - Picks Beta (Modular) vs LogNormal (all other variants).
829/// * `rng` - Seeded RNG.
830///
831/// ### Returns
832///
833/// Loadings vector of length `num_genes`, or
834/// [`CoreErrors::BetaDistribution`] / [`CoreErrors::InvalidArgument`]
835/// on distribution construction failure.
836fn draw_loadings<T: BixverseFloat>(
837    gene_modules: &[usize],
838    num_genes: usize,
839    generator: &BulkDataGenerator<T>,
840    rng: &mut StdRng,
841) -> Result<Vec<f64>, CoreErrors> {
842    let mut loadings = vec![0.0f64; num_genes];
843    match generator {
844        BulkDataGenerator::Modular(_) => {
845            let beta =
846                Beta::new(5.0, 2.0).map_err(|e| CoreErrors::BetaDistribution(e.to_string()))?;
847            for (i, &m) in gene_modules.iter().enumerate() {
848                if m > 0 {
849                    loadings[i] = beta.sample(rng);
850                }
851            }
852        }
853        _ => {
854            let l = generator.loading().expect("non-Modular has loading");
855            let ln = LogNormal::new(l.mu.to_f64().unwrap(), l.sigma.to_f64().unwrap())
856                .map_err(|e| CoreErrors::InvalidArgument(format!("lognormal init: {e}")))?;
857            for (i, &m) in gene_modules.iter().enumerate() {
858                if m > 0 {
859                    loadings[i] = ln.sample(rng);
860                }
861            }
862        }
863    }
864    Ok(loadings)
865}
866
867/// Flag the top-fraction of module-gene loadings as hubs.
868///
869/// Percentile is computed globally across all module genes (not per module),
870/// so a module with unusually large loadings will get proportionally more
871/// hubs, which matches the intuition that some modules are hub-dense and
872/// others aren't.
873///
874/// ### Params
875///
876/// * `gene_modules` - Length `num_genes`; `0` means background.
877/// * `loadings` - Per-gene loading values.
878/// * `hub_percentile` - Fraction in `(0, 1]`.
879///
880/// ### Returns
881///
882/// Sorted indices of the hub genes. Empty if there are no module genes.
883fn identify_hubs(gene_modules: &[usize], loadings: &[f64], hub_percentile: f64) -> Vec<usize> {
884    let mut module_idx: Vec<usize> = gene_modules
885        .iter()
886        .enumerate()
887        .filter_map(|(i, &m)| if m > 0 { Some(i) } else { None })
888        .collect();
889    if module_idx.is_empty() {
890        return Vec::new();
891    }
892    module_idx.sort_by(|&a, &b| {
893        loadings[b]
894            .partial_cmp(&loadings[a])
895            .unwrap_or(std::cmp::Ordering::Equal)
896    });
897    let k = ((module_idx.len() as f64 * hub_percentile).ceil() as usize).max(1);
898    let mut hubs: Vec<usize> = module_idx.into_iter().take(k).collect();
899    hubs.sort_unstable();
900    hubs
901}
902
903/// Sample from `NegBin(r, mu)` via a Gamma-Poisson mixture.
904///
905/// `r` is the NB size, `mu` the mean. Returns 0 when `mu` is small enough
906/// that the Gamma scale would underflow (protects against zero-mean latent
907/// signal after log-scale noise pushes into the extreme left tail) and when
908/// either distribution constructor otherwise rejects — the caller doesn't
909/// need to distinguish "mean was zero" from "sampler said no".
910///
911/// ### Params
912///
913/// * `r` - NB size (inverse dispersion).
914/// * `mu` - NB mean.
915/// * `rng` - RNG.
916///
917/// ### Returns
918///
919/// A single non-negative count as `f64` (integer-valued in practice).
920#[inline]
921fn nb_sample(r: f64, mu: f64, rng: &mut StdRng) -> f64 {
922    // rand_distr Gamma rejects scale <= f64::MIN_POSITIVE. scale = (1-p)/p = mu/r,
923    // so mu < r * MIN_POSITIVE is unusable. Also protect against mu near 0
924    // producing a Poisson lambda that Poisson::new would reject.
925    let min_mu = r * f64::MIN_POSITIVE.max(1e-300) * 1e6;
926    if mu <= min_mu || !mu.is_finite() {
927        return 0.0;
928    }
929    let p = r / (r + mu);
930    let scale = (1.0 - p) / p;
931    let gamma = match Gamma::new(r, scale) {
932        Ok(g) => g,
933        Err(_) => return 0.0,
934    };
935    let lambda = gamma.sample(rng);
936    if !(lambda > 0.0 && lambda.is_finite()) {
937        return 0.0;
938    }
939    match Poisson::new(lambda) {
940        Ok(p) => p.sample(rng),
941        Err(_) => 0.0,
942    }
943}
944
945////////////////////
946// Sparsification //
947////////////////////
948
949/// Apply a dropout / sparsification strategy to a dense count matrix.
950///
951/// ### Params
952///
953/// * `counts` - Input count matrix, shape `(n_genes, n_samples)`. Values are
954///   read as f64 counts; non-integer inputs are floored inside the binomial
955///   thinning step.
956/// * `params` - See [`SparsityParams`].
957///
958/// ### Returns
959///
960/// A new count matrix with the same shape as `counts`, sparsified per the
961/// strategy. Errors propagate through [`CoreErrors::InvalidArgument`].
962///
963/// ### References
964///
965/// Zappia et al. 2017 (Splatter). Per-sample size factors from a LogNormal
966/// with a target library size, then binomial thinning per gene.
967pub fn apply_dropout<T>(counts: MatRef<T>, params: &SparsityParams<T>) -> Result<Mat<T>, CoreErrors>
968where
969    T: BixverseFloat,
970{
971    params.validate()?;
972    match params.strategy {
973        DropoutStrategy::SequencingDepth => apply_dropout_seq_depth(counts, params),
974    }
975}
976
977/// Splatter-style sequencing-depth dropout.
978///
979/// Per sample j, draw a size factor `s_j ~ LogNormal(0, sigma_s)`, compute a
980/// target library `L_j = target * s_j`, and binomially thin each gene's
981/// count so the column library approaches `L_j`. Retention probability is
982/// capped at 1 — when the target exceeds the current library, no counts are
983/// added (we don't upsample).
984///
985/// ### Params
986///
987/// * `counts` - Input count matrix, shape `(n_genes, n_samples)`.
988/// * `params` - See [`SparsityParams`].
989///
990/// ### Returns
991///
992/// A new count matrix, same shape as `counts`.
993fn apply_dropout_seq_depth<T>(
994    counts: MatRef<T>,
995    params: &SparsityParams<T>,
996) -> Result<Mat<T>, CoreErrors>
997where
998    T: BixverseFloat,
999{
1000    let (n_genes, n_samples) = counts.shape();
1001    let seed = params.seed;
1002    let target_lib = params.target_library_size.to_f64().unwrap();
1003    let sigma_s = params.capture_efficiency_sigma.to_f64().unwrap();
1004
1005    // per-sample size factor and current library
1006    let mut rng = StdRng::seed_from_u64(seed);
1007    let ln = LogNormal::new(0.0, sigma_s)
1008        .map_err(|e| CoreErrors::InvalidArgument(format!("lognormal(seq_depth) init: {e}")))?;
1009    let size_factors: Vec<f64> = (0..n_samples).map(|_| ln.sample(&mut rng)).collect();
1010    let current_lib: Vec<f64> = (0..n_samples)
1011        .map(|j| {
1012            (0..n_genes)
1013                .map(|i| counts.get(i, j).to_f64().unwrap())
1014                .sum()
1015        })
1016        .collect();
1017
1018    // per-sample retention probability, capped at 1.0
1019    let retention: Vec<f64> = (0..n_samples)
1020        .map(|j| {
1021            let target_j = target_lib * size_factors[j];
1022            if current_lib[j] > 0.0 {
1023                (target_j / current_lib[j]).min(1.0)
1024            } else {
1025                0.0
1026            }
1027        })
1028        .collect();
1029
1030    // parallel over samples: per-column binomial thinning. Columns are the
1031    // faer-native storage axis so this is cache-friendly.
1032    let per_sample: Vec<Vec<T>> = (0..n_samples)
1033        .into_par_iter()
1034        .map(|j| {
1035            let mut local_rng = StdRng::seed_from_u64(seed.wrapping_add((j as u64) * 1000 + 7));
1036            let p = retention[j];
1037            let mut col = Vec::with_capacity(n_genes);
1038            for i in 0..n_genes {
1039                let c = counts.get(i, j).to_f64().unwrap();
1040                let n = c.max(0.0).floor() as u64;
1041                let out = if n == 0 || p <= 0.0 {
1042                    0.0
1043                } else if p >= 1.0 {
1044                    n as f64
1045                } else {
1046                    let b = Binomial::new(n, p).unwrap();
1047                    b.sample(&mut local_rng) as f64
1048                };
1049                col.push(T::from_f64(out).unwrap());
1050            }
1051            col
1052        })
1053        .collect();
1054
1055    let mut out: Mat<T> = Mat::zeros(n_genes, n_samples);
1056    for (j, col) in per_sample.into_iter().enumerate() {
1057        for (i, v) in col.into_iter().enumerate() {
1058            out[(i, j)] = v;
1059        }
1060    }
1061    Ok(out)
1062}
1063
1064///////////
1065// Tests //
1066///////////
1067
1068#[cfg(test)]
1069mod tests {
1070    use super::*;
1071
1072    // ---- helpers -----------------------------------------------------
1073
1074    /// log1p on every entry.
1075    fn log1p_mat(counts: &Mat<f64>) -> Mat<f64> {
1076        let (r, c) = counts.shape();
1077        let mut out = Mat::<f64>::zeros(r, c);
1078        for i in 0..r {
1079            for j in 0..c {
1080                out[(i, j)] = (counts[(i, j)] + 1.0).ln();
1081            }
1082        }
1083        out
1084    }
1085
1086    /// Pearson correlation on two equal-length slices.
1087    fn pearson(x: &[f64], y: &[f64]) -> f64 {
1088        let n = x.len() as f64;
1089        let mx = x.iter().sum::<f64>() / n;
1090        let my = y.iter().sum::<f64>() / n;
1091        let mut num = 0.0;
1092        let mut sx = 0.0;
1093        let mut sy = 0.0;
1094        for (a, b) in x.iter().zip(y.iter()) {
1095            let da = *a - mx;
1096            let db = *b - my;
1097            num += da * db;
1098            sx += da * da;
1099            sy += db * db;
1100        }
1101        let denom = (sx * sy).sqrt();
1102        if denom > 0.0 { num / denom } else { 0.0 }
1103    }
1104
1105    /// Full gene x gene pearson correlation on a `(n_genes, n_samples)` matrix.
1106    fn gene_gene_corr(log_mat: &Mat<f64>) -> Mat<f64> {
1107        let (n, s) = log_mat.shape();
1108        let mut rows: Vec<Vec<f64>> = Vec::with_capacity(n);
1109        for i in 0..n {
1110            rows.push((0..s).map(|j| log_mat[(i, j)]).collect());
1111        }
1112        let mut out = Mat::<f64>::zeros(n, n);
1113        for i in 0..n {
1114            for j in i..n {
1115                let r = pearson(&rows[i], &rows[j]);
1116                out[(i, j)] = r;
1117                out[(j, i)] = r;
1118            }
1119        }
1120        out
1121    }
1122
1123    /// Extract row `i` of a `Mat<f64>` as a `Vec<f64>`.
1124    fn take_row(m: &Mat<f64>, i: usize) -> Vec<f64> {
1125        (0..m.ncols()).map(|j| m[(i, j)]).collect()
1126    }
1127
1128    /// Excess kurtosis of a slice. Normal distribution → 0.
1129    fn excess_kurtosis(x: &[f64]) -> f64 {
1130        let n = x.len() as f64;
1131        let m = x.iter().sum::<f64>() / n;
1132        let mut m2 = 0.0;
1133        let mut m4 = 0.0;
1134        for &v in x {
1135            let d = v - m;
1136            m2 += d * d;
1137            m4 += d * d * d * d;
1138        }
1139        m2 /= n;
1140        m4 /= n;
1141        if m2 > 0.0 { m4 / (m2 * m2) - 3.0 } else { 0.0 }
1142    }
1143
1144    fn hub_modular_default() -> BulkDataGenerator<f64> {
1145        BulkDataGenerator::HubModular(HubModularConfig::default())
1146    }
1147
1148    // ---- basic dimensions --------------------------------------------
1149
1150    /// With no modules every gene lands in module 0 and the factor matrix is empty but shaped.
1151    #[test]
1152    fn dims_no_modules() {
1153        let params = SyntheticRnaSeqParams::<f64> {
1154            num_samples: 10,
1155            num_genes: 50,
1156            module_sizes: vec![],
1157            ..Default::default()
1158        };
1159        let data = generate_bulk_rnaseq(&params).unwrap();
1160        assert_eq!(data.count_matrix.nrows(), 50);
1161        assert_eq!(data.count_matrix.ncols(), 10);
1162        assert_eq!(data.gene_modules.len(), 50);
1163        assert!(data.gene_modules.iter().all(|&m| m == 0));
1164        assert!(data.module_hubs.is_empty());
1165        assert_eq!(data.module_factors.nrows(), 0);
1166        assert_eq!(data.module_factors.ncols(), 10);
1167    }
1168
1169    /// Module sizes are honoured exactly, the remainder is background, and hubs sit inside modules.
1170    #[test]
1171    fn dims_with_modules() {
1172        let params = SyntheticRnaSeqParams::<f64> {
1173            num_samples: 20,
1174            num_genes: 100,
1175            module_sizes: vec![20, 30],
1176            generator: hub_modular_default(),
1177            ..Default::default()
1178        };
1179        let data = generate_bulk_rnaseq(&params).unwrap();
1180        assert_eq!(data.count_matrix.nrows(), 100);
1181        let counts_by_id = |k: usize| data.gene_modules.iter().filter(|&&m| m == k).count();
1182        assert_eq!(counts_by_id(1), 20);
1183        assert_eq!(counts_by_id(2), 30);
1184        assert_eq!(counts_by_id(0), 50);
1185        assert_eq!(data.module_factors.nrows(), 2);
1186        assert_eq!(data.module_factors.ncols(), 20);
1187        assert!(!data.module_hubs.is_empty());
1188        assert!(data.module_hubs.iter().all(|&i| data.gene_modules[i] > 0));
1189    }
1190
1191    // ---- validation --------------------------------------------------
1192
1193    /// Modules larger than the gene count are refused rather than silently truncated.
1194    #[test]
1195    fn invalid_module_sizes_rejected() {
1196        let params = SyntheticRnaSeqParams::<f64> {
1197            num_samples: 5,
1198            num_genes: 10,
1199            module_sizes: vec![20],
1200            ..Default::default()
1201        };
1202        assert!(matches!(
1203            generate_bulk_rnaseq(&params),
1204            Err(CoreErrors::InvalidArgument(_))
1205        ));
1206    }
1207
1208    // ---- correlation structure --------------------------------------
1209
1210    /// The point of the generator: co-module genes must correlate more than cross-module ones.
1211    #[test]
1212    fn within_module_corr_stronger_than_cross() {
1213        let params = SyntheticRnaSeqParams::<f64> {
1214            num_samples: 200,
1215            num_genes: 110,
1216            seed: 7,
1217            module_sizes: vec![30, 30],
1218            generator: hub_modular_default(),
1219            ..Default::default()
1220        };
1221        let data = generate_bulk_rnaseq(&params).unwrap();
1222        let log = log1p_mat(&data.count_matrix);
1223        let corr = gene_gene_corr(&log);
1224        let mods = &data.gene_modules;
1225
1226        let (mut in_sum, mut in_n) = (0.0, 0usize);
1227        let (mut xr_sum, mut xr_n) = (0.0, 0usize);
1228        for i in 0..mods.len() {
1229            for j in (i + 1)..mods.len() {
1230                if mods[i] == 0 || mods[j] == 0 {
1231                    continue;
1232                }
1233                if mods[i] == mods[j] {
1234                    in_sum += corr[(i, j)];
1235                    in_n += 1;
1236                } else {
1237                    xr_sum += corr[(i, j)];
1238                    xr_n += 1;
1239                }
1240            }
1241        }
1242        let in_mean = in_sum / in_n as f64;
1243        let cross_mean = xr_sum / xr_n as f64;
1244        assert!(
1245            in_mean - cross_mean > 0.15,
1246            "within-module mean {in_mean} - cross-module mean {cross_mean} < 0.15"
1247        );
1248    }
1249
1250    /// The hub-modular generator must produce a real degree gradient, not uniform modules.
1251    #[test]
1252    fn hubs_have_higher_degree_than_non_hubs() {
1253        let params = SyntheticRnaSeqParams::<f64> {
1254            num_samples: 200,
1255            num_genes: 100,
1256            seed: 11,
1257            module_sizes: vec![60],
1258            generator: hub_modular_default(),
1259            ..Default::default()
1260        };
1261        let data = generate_bulk_rnaseq(&params).unwrap();
1262        let log = log1p_mat(&data.count_matrix);
1263        let corr = gene_gene_corr(&log);
1264        let n = corr.nrows();
1265
1266        let threshold = 0.5;
1267        let mut degrees = vec![0usize; n];
1268        for i in 0..n {
1269            for j in 0..n {
1270                if i != j && corr[(i, j)].abs() >= threshold {
1271                    degrees[i] += 1;
1272                }
1273            }
1274        }
1275
1276        let hubs: std::collections::HashSet<usize> = data.module_hubs.iter().copied().collect();
1277        let mut hub_sum = 0.0;
1278        let mut hub_n = 0.0;
1279        let mut non_hub_sum = 0.0;
1280        let mut non_hub_n = 0.0;
1281        for (i, &m) in data.gene_modules.iter().enumerate() {
1282            if m == 0 {
1283                continue;
1284            }
1285            if hubs.contains(&i) {
1286                hub_sum += degrees[i] as f64;
1287                hub_n += 1.0;
1288            } else {
1289                non_hub_sum += degrees[i] as f64;
1290                non_hub_n += 1.0;
1291            }
1292        }
1293        let hub_mean = hub_sum / hub_n;
1294        let non_hub_mean = non_hub_sum / non_hub_n;
1295        assert!(
1296            hub_mean > non_hub_mean,
1297            "hub mean degree {hub_mean} not > non-hub {non_hub_mean}"
1298        );
1299    }
1300
1301    // ---- reproducibility --------------------------------------------
1302
1303    /// Everything is seeded, so two runs at one seed must be bit-identical, not merely similar.
1304    #[test]
1305    fn same_seed_same_output() {
1306        let params = SyntheticRnaSeqParams::<f64> {
1307            num_samples: 12,
1308            num_genes: 40,
1309            seed: 999,
1310            module_sizes: vec![10, 10],
1311            ..Default::default()
1312        };
1313        let a = generate_bulk_rnaseq(&params).unwrap();
1314        let b = generate_bulk_rnaseq(&params).unwrap();
1315        for i in 0..a.count_matrix.nrows() {
1316            for j in 0..a.count_matrix.ncols() {
1317                assert_eq!(a.count_matrix[(i, j)], b.count_matrix[(i, j)]);
1318            }
1319        }
1320        for m in 0..a.module_factors.nrows() {
1321            for j in 0..a.module_factors.ncols() {
1322                assert_eq!(a.module_factors[(m, j)], b.module_factors[(m, j)]);
1323            }
1324        }
1325    }
1326
1327    // ---- module factor round-trip ------------------------------------
1328
1329    /// The returned module factors are the real latent signal, recoverable as PC1 of the counts.
1330    #[test]
1331    fn module_pc1_tracks_ground_truth_factor() {
1332        let params = SyntheticRnaSeqParams::<f64> {
1333            num_samples: 150,
1334            num_genes: 60,
1335            seed: 21,
1336            module_sizes: vec![60],
1337            generator: hub_modular_default(),
1338            noise_std: 0.3,
1339            ..Default::default()
1340        };
1341        let data = generate_bulk_rnaseq(&params).unwrap();
1342        let log = log1p_mat(&data.count_matrix);
1343        let (n, s) = log.shape();
1344        let mut centered = Mat::<f64>::zeros(n, s);
1345        for i in 0..n {
1346            let mut m = 0.0;
1347            for j in 0..s {
1348                m += log[(i, j)];
1349            }
1350            m /= s as f64;
1351            for j in 0..s {
1352                centered[(i, j)] = log[(i, j)] - m;
1353            }
1354        }
1355        let svd = centered.thin_svd().expect("thin_svd");
1356        let v = svd.V();
1357        let pc1: Vec<f64> = (0..s).map(|j| v[(j, 0)]).collect();
1358        let factor = take_row(
1359            &Mat::<f64>::from_fn(1, s, |_, j| data.module_factors[(0, j)]),
1360            0,
1361        );
1362        let r = pearson(&pc1, &factor).abs();
1363        assert!(r > 0.7, "PC1 vs ground-truth factor |r|={r} <= 0.7");
1364    }
1365
1366    // ---- non-negative factor variant ---------------------------------
1367
1368    /// The NMF-flavoured generator must keep factors non-negative without losing module structure.
1369    #[test]
1370    fn non_negative_factor_is_non_negative() {
1371        let params = SyntheticRnaSeqParams::<f64> {
1372            num_samples: 100,
1373            num_genes: 60,
1374            seed: 15,
1375            module_sizes: vec![30],
1376            generator: BulkDataGenerator::NonNegativeFactor(NonNegativeFactorConfig::default()),
1377            ..Default::default()
1378        };
1379        let data = generate_bulk_rnaseq(&params).unwrap();
1380        for m in 0..data.module_factors.nrows() {
1381            for j in 0..data.module_factors.ncols() {
1382                assert!(
1383                    data.module_factors[(m, j)] >= 0.0,
1384                    "factor {} at (m={m}, j={j}) is negative",
1385                    data.module_factors[(m, j)]
1386                );
1387            }
1388        }
1389        // sanity: recovery still works
1390        let log = log1p_mat(&data.count_matrix);
1391        let corr = gene_gene_corr(&log);
1392        let mods = &data.gene_modules;
1393        let (mut in_sum, mut in_n) = (0.0, 0usize);
1394        for i in 0..mods.len() {
1395            for j in (i + 1)..mods.len() {
1396                if mods[i] > 0 && mods[i] == mods[j] {
1397                    in_sum += corr[(i, j)];
1398                    in_n += 1;
1399                }
1400            }
1401        }
1402        assert!(
1403            in_sum / in_n as f64 > 0.1,
1404            "non-negative variant not producing correlation"
1405        );
1406    }
1407
1408    // ---- non-Gaussian factor variant ---------------------------------
1409
1410    /// The ICA-flavoured generator must be heavy tailed, otherwise it is no test for ICA at all.
1411    #[test]
1412    fn non_gaussian_factor_is_non_gaussian() {
1413        let params = SyntheticRnaSeqParams::<f64> {
1414            num_samples: 500,
1415            num_genes: 60,
1416            seed: 23,
1417            module_sizes: vec![30],
1418            generator: BulkDataGenerator::NonGaussianFactor(NonGaussianFactorConfig::default()),
1419            ..Default::default()
1420        };
1421        let data = generate_bulk_rnaseq(&params).unwrap();
1422        let f: Vec<f64> = (0..data.module_factors.ncols())
1423            .map(|j| data.module_factors[(0, j)])
1424            .collect();
1425        // Laplace excess kurtosis is 3.
1426        let ex_k = excess_kurtosis(&f);
1427        assert!(
1428            ex_k > 1.0,
1429            "excess kurtosis {ex_k} not clearly non-Gaussian (Laplace expected ~3)"
1430        );
1431    }
1432
1433    // ---- sparsification ---------------------------------------------
1434
1435    /// Sequencing-depth dropout removes counts; it must not rescale them upwards.
1436    #[test]
1437    fn dropout_reduces_total_counts() {
1438        let base = SyntheticRnaSeqParams::<f64> {
1439            num_samples: 30,
1440            num_genes: 100,
1441            seed: 3,
1442            module_sizes: vec![],
1443            ..Default::default()
1444        };
1445        let data = generate_bulk_rnaseq(&base).unwrap();
1446        let params = SparsityParams::<f64> {
1447            strategy: DropoutStrategy::SequencingDepth,
1448            target_library_size: 100.0,
1449            capture_efficiency_sigma: 0.5,
1450            seed: 4,
1451        };
1452        let dropped = apply_dropout(data.count_matrix.as_ref(), &params).unwrap();
1453        let mut sum_before = 0.0;
1454        for i in 0..data.count_matrix.nrows() {
1455            for j in 0..data.count_matrix.ncols() {
1456                sum_before += data.count_matrix[(i, j)];
1457            }
1458        }
1459        let mut sum_after = 0.0;
1460        for i in 0..dropped.nrows() {
1461            for j in 0..dropped.ncols() {
1462                sum_after += dropped[(i, j)];
1463            }
1464        }
1465        assert!(
1466            sum_after < sum_before,
1467            "sum_after {sum_after} >= sum_before {sum_before}"
1468        );
1469    }
1470
1471    /// Dropout may weaken the module signal but must not invert it.
1472    #[test]
1473    fn dropout_preserves_within_module_correlation_sign() {
1474        let base = SyntheticRnaSeqParams::<f64> {
1475            num_samples: 200,
1476            num_genes: 80,
1477            seed: 5,
1478            module_sizes: vec![40],
1479            generator: hub_modular_default(),
1480            ..Default::default()
1481        };
1482        let data = generate_bulk_rnaseq(&base).unwrap();
1483        let params = SparsityParams::<f64> {
1484            strategy: DropoutStrategy::SequencingDepth,
1485            target_library_size: 1_000.0,
1486            capture_efficiency_sigma: 0.2,
1487            seed: 6,
1488        };
1489        let dropped = apply_dropout(data.count_matrix.as_ref(), &params).unwrap();
1490        let log = log1p_mat(&dropped);
1491        let corr = gene_gene_corr(&log);
1492        let mods = &data.gene_modules;
1493        let (mut in_sum, mut in_n) = (0.0, 0usize);
1494        for i in 0..mods.len() {
1495            for j in (i + 1)..mods.len() {
1496                if mods[i] > 0 && mods[i] == mods[j] {
1497                    in_sum += corr[(i, j)];
1498                    in_n += 1;
1499                }
1500            }
1501        }
1502        let mean = in_sum / in_n as f64;
1503        assert!(
1504            mean > 0.0,
1505            "within-module mean corr {mean} not positive after dropout"
1506        );
1507    }
1508
1509    /// The dropout RNG is seeded separately from the generator and is equally reproducible.
1510    #[test]
1511    fn dropout_reproducible() {
1512        let base = SyntheticRnaSeqParams::<f64> {
1513            num_samples: 15,
1514            num_genes: 40,
1515            seed: 8,
1516            module_sizes: vec![],
1517            ..Default::default()
1518        };
1519        let data = generate_bulk_rnaseq(&base).unwrap();
1520        let params = SparsityParams::<f64> {
1521            strategy: DropoutStrategy::SequencingDepth,
1522            target_library_size: 500.0,
1523            capture_efficiency_sigma: 0.5,
1524            seed: 99,
1525        };
1526        let a = apply_dropout(data.count_matrix.as_ref(), &params).unwrap();
1527        let b = apply_dropout(data.count_matrix.as_ref(), &params).unwrap();
1528        for i in 0..a.nrows() {
1529            for j in 0..a.ncols() {
1530                assert_eq!(a[(i, j)], b[(i, j)]);
1531            }
1532        }
1533    }
1534
1535    // ---- parse helpers ----------------------------------------------
1536
1537    /// The R-facing strings, aliases and casing all still map onto the right generator variants.
1538    #[test]
1539    fn parse_helpers_work() {
1540        assert!(matches!(
1541            parse_bulk_data_generator::<f64>("modular"),
1542            Some(BulkDataGenerator::Modular(_))
1543        ));
1544        assert!(matches!(
1545            parse_bulk_data_generator::<f64>("Hub_Modular"),
1546            Some(BulkDataGenerator::HubModular(_))
1547        ));
1548        assert!(matches!(
1549            parse_bulk_data_generator::<f64>("nmf"),
1550            Some(BulkDataGenerator::NonNegativeFactor(_))
1551        ));
1552        assert!(matches!(
1553            parse_bulk_data_generator::<f64>("ica"),
1554            Some(BulkDataGenerator::NonGaussianFactor(_))
1555        ));
1556        assert!(parse_bulk_data_generator::<f64>("nope").is_none());
1557        assert_eq!(
1558            parse_dropout_strategy("seq_depth"),
1559            Some(DropoutStrategy::SequencingDepth)
1560        );
1561        assert_eq!(parse_dropout_strategy("nope"), None);
1562    }
1563}