Skip to main content

edge_rs/
splicing.rs

1//! `diffSpliceDGE`: differential exon usage.
2//!
3//! Tests whether one exon changed *relative to the rest of its gene*. The
4//! gene's log-fold-change is estimated from the summed counts and folded into
5//! the offsets as a fixed shift; the exon is then tested for the fold change
6//! left over.
7//!
8//! Three entry points: [`diff_splice`] takes a fitted GLM and does the work,
9//! [`diff_splice_dge`] is the [`DgeList`] wrapper that fits first, and
10//! [`splice_variants`] is edgeR's per-gene interaction test (one likelihood
11//! ratio test on an exon-by-group layout). See `UPSTREAM_DEVIATIONS.md` A17
12//! and A18.
13//!
14//! ### Parallelism
15//!
16//! Both axes are used. The refits fan out over rows inside [`glm_fit`] (exons
17//! for the exon-level fit, genes for the gene-level one), per-exon tail
18//! probabilities fan out over exons, and the per-gene aggregation (which sorts
19//! p-values for Simes) fans out over genes. The scatter that sums exons into
20//! genes is sequential; a parallel reduction costs more than it saves.
21//!
22//! ### Layout
23//!
24//! Exons take the place of genes in the row-major convention: counts are
25//! `n_exons * n_samples` and `gene_id` labels each row. Rows need not be sorted
26//! or contiguous by gene; genes come out in first-appearance order. edgeR sorts
27//! by gene id first, so the two agree on sorted input.
28
29use rayon::prelude::*;
30use rustc_hash::FxHashMap;
31
32use crate::core::dgelist::DgeList;
33use crate::glm::fit::{DEFAULT_PRIOR_COUNT, GlmFit, glm_fit, glm_fit_dge};
34use crate::glm::test::{GlmTestInput, Tested, glm_lrt};
35use crate::limma::squeeze_var::{SqueezeVarParams, squeeze_var};
36use crate::numeric::dist::{chisq_sf, f_sf};
37use crate::prelude::*;
38use crate::utils::design::contrast_as_coef;
39
40////////////
41// Consts //
42////////////
43
44/// Dispersion the gene-level fit runs at.
45///
46/// Hard-coded in edgeR. The gene-level coefficient only serves as a fixed
47/// offset for the exon-level test, so its standard error never reaches a
48/// p-value.
49const GENE_LEVEL_DISPERSION: f64 = 0.05;
50
51//////////////////////
52// DiffSpliceParams //
53//////////////////////
54
55/// Tuning knobs for [`diff_splice`] and [`diff_splice_dge`].
56#[derive(Clone, Copy, Debug)]
57pub struct DiffSpliceParams {
58    /// Prior count for the gene-level fit's log-fold-change shrinkage. The
59    /// exon-level fit is the caller's and the reduced-model refit needs no
60    /// shrunk coefficients.
61    pub prior_count: f64,
62    /// Whether the gene-level empirical Bayes step Winsorises its moments.
63    /// `None` follows edgeR: robust when the quasi-likelihood fit produced a
64    /// per-gene `df_prior`. Ignored on the likelihood ratio path.
65    pub robust: Option<bool>,
66}
67
68impl DiffSpliceParams {
69    /// Builds a parameter set.
70    ///
71    /// ### Params
72    ///
73    /// * `prior_count` - Shrinkage prior for the gene-level fit, non-negative
74    /// * `robust` - Robust empirical Bayes, or `None` to let the fit decide
75    ///
76    /// ### Returns
77    ///
78    /// The parameter set. Validation happens in [`diff_splice`].
79    pub fn new(prior_count: f64, robust: Option<bool>) -> Self {
80        Self {
81            prior_count,
82            robust,
83        }
84    }
85}
86
87impl Default for DiffSpliceParams {
88    /// edgeR's defaults: a prior count of 0.125 and an automatic `robust`.
89    fn default() -> Self {
90        Self {
91            prior_count: DEFAULT_PRIOR_COUNT,
92            robust: None,
93        }
94    }
95}
96
97//////////////////
98// DiffSpliceQl //
99//////////////////
100
101/// The quasi-likelihood quantities [`diff_splice`] needs from `glmQLFit`.
102///
103/// Passing `Some` switches the exon and gene tests from chi-squared to
104/// moderated F. Fields map onto `crate::glm::ql_fit::QlFit`:
105///
106/// | field | legacy pipeline | current pipeline |
107/// |---|---|---|
108/// | `df_residual` | `df_residual_zeros` | `df_residual_adj` |
109/// | `deviance` | `deviance` | `deviance_adj` |
110/// | `legacy_zeros` | `true` | `false` |
111/// | `average_ql_dispersion` | `None` | `average_ql_dispersion` |
112///
113/// The fit's `s2_post` is absent: `diffSpliceDGE` discards the exon-level
114/// squeezing and squeezes again at the gene level.
115#[derive(Clone, Copy, Debug)]
116pub struct DiffSpliceQl<'a> {
117    /// Prior degrees of freedom from the fit, one value or one per exon. Only
118    /// its length is read, to default `robust`.
119    pub df_prior: &'a [f64],
120    /// Residual degrees of freedom per exon that go with `deviance`.
121    pub df_residual: &'a [f64],
122    /// Residual deviance per exon that goes with `df_residual`.
123    pub deviance: &'a [f64],
124    /// Whether `df_residual` came from `df.residual.zeros`.
125    ///
126    /// Switches on edgeR's chi-squared floor: an exon in a gene whose posterior
127    /// quasi-dispersion fell below one gets no p-value smaller than the
128    /// unmoderated chi-squared gives. Only the legacy pipeline (which drops
129    /// structural zeros from the df) can land there spuriously.
130    pub legacy_zeros: bool,
131    /// `average.ql.dispersion`, which divides the dispersion before the reduced
132    /// model is refitted so both models share a scale. `None` on the legacy
133    /// pipeline.
134    pub average_ql_dispersion: Option<f64>,
135}
136
137//////////////////////
138// DiffSpliceResult //
139//////////////////////
140
141/// Result of a differential exon usage analysis.
142///
143/// Exon-level vectors have one entry per input exon, in input order. Gene-level
144/// vectors have one entry per *testable* gene, in first-appearance order.
145/// Single-exon genes have no within-gene contrast and are dropped.
146#[derive(Clone, Debug)]
147pub struct DiffSpliceResult {
148    /// Exon-level log fold change relative to the gene.
149    ///
150    /// The exon's coefficient minus its gene's, on the **natural** log scale as
151    /// in edgeR's `coefficients` slot. (`topSpliceDGE` labels this `logFC`.)
152    ///
153    /// Zero for an exon whose gene was dropped.
154    pub exon_log_fc: Vec<f64>,
155    /// Exon-level test statistic and p-value.
156    ///
157    /// A likelihood ratio when `ql` was `None`, a moderated F otherwise. Exons
158    /// of dropped genes get a zero statistic and a p-value of one.
159    pub exon_statistic: Vec<f64>,
160    /// Exon-level p-value. See [`DiffSpliceResult::exon_statistic`].
161    pub exon_p_value: Vec<f64>,
162    /// Gene-level identifiers, one per gene, in first-appearance order.
163    pub gene_id: Vec<usize>,
164    /// Gene-level Simes and F tests.
165    ///
166    /// The Simes p-value is `min over k of (n * p_(k) / k)` on the gene's
167    /// sorted exon p-values: does the gene contain any differentially used exon.
168    pub gene_simes_p: Vec<f64>,
169    /// Joint gene-level statistic: the summed exon statistics, tested against a
170    /// chi-squared (likelihood ratio) or an F (quasi-likelihood).
171    pub gene_f_statistic: Vec<f64>,
172    /// P-value for [`DiffSpliceResult::gene_f_statistic`].
173    pub gene_f_p_value: Vec<f64>,
174    /// Number of exons per gene.
175    pub gene_n_exons: Vec<usize>,
176}
177
178/// Tests for differential exon usage.
179///
180/// Port of edgeR's `diffSpliceDGE`. Given an exon-level negative binomial GLM:
181///
182/// 1. sum each gene's exon counts and fit the same design to the totals, giving
183///    the gene's log-fold-change `betabar`,
184/// 2. fold `betabar * design[, coef]` into the exon-level offsets,
185/// 3. refit each exon under the reduced design with `coef` dropped, and
186/// 4. read the deviance difference as evidence that the exon moved by more
187///    than its gene did.
188///
189/// Single-exon genes are dropped.
190///
191/// ### Params
192///
193/// * `input` - Counts, design and recycled matrices behind the fit, with exons
194///   as rows: `input.n_genes` is the exon count, `input.counts` is
195///   `n_exons * n_samples`
196/// * `fit` - The exon-level fit, from [`glm_fit`] or `glmQLFit`. Only its
197///   coefficients, deviance and residual degrees of freedom are read
198/// * `gene_id` - Gene label per exon, one per row of `input.counts`
199/// * `tested` - Which coefficient carries the comparison. Only one is testable:
200///   extra entries of [`Tested::Coef`], and extra columns of
201///   [`Tested::Contrast`], are dropped, as edgeR does with a warning
202/// * `ql` - Quasi-likelihood quantities from `glmQLFit`, or `None` for the
203///   likelihood ratio flavour
204/// * `params` - Tuning knobs, or [`DiffSpliceParams::default`]
205///
206/// ### Returns
207///
208/// The exon and gene level tests, or [`EdgeErrors`] if a shape disagrees, the
209/// design has fewer than two columns, the coefficient is out of range, the
210/// contrast is rank deficient, or no gene has more than one exon.
211///
212/// ### References
213///
214/// Chen, Lun and Smyth, F1000Research, 2016
215pub fn diff_splice<T: EdgeFloat>(
216    input: &GlmTestInput<'_, T>,
217    fit: &GlmFit,
218    gene_id: &[usize],
219    tested: &Tested,
220    ql: Option<&DiffSpliceQl<'_>>,
221    params: Option<DiffSpliceParams>,
222) -> Result<DiffSpliceResult, EdgeErrors> {
223    let params = params.unwrap_or_default();
224    validate(input, fit, gene_id, ql, &params)?;
225
226    let n_exons = input.n_genes;
227    let n_samples = input.n_samples;
228    let n_coef = input.n_coef;
229
230    let resolved = resolve_tested(input, fit, tested)?;
231    let groups = group_by_gene(gene_id);
232
233    let kept_genes: Vec<usize> = (0..groups.ids.len())
234        .filter(|g| groups.members[*g].len() > 1)
235        .collect();
236    if kept_genes.is_empty() {
237        return Err(EdgeErrors::InvalidArgument(
238            "no gene has more than one exon, so there is no within-gene contrast to test"
239                .to_string(),
240        ));
241    }
242
243    let mut kept_exons: Vec<usize> = Vec::new();
244    let mut exon_gene: Vec<usize> = Vec::new();
245    let mut gene_start: Vec<usize> = Vec::with_capacity(kept_genes.len());
246    for (slot, &gene) in kept_genes.iter().enumerate() {
247        gene_start.push(kept_exons.len());
248        for &exon in &groups.members[gene] {
249            kept_exons.push(exon);
250            exon_gene.push(slot);
251        }
252    }
253    let n_kept_genes = kept_genes.len();
254    let n_kept_exons = kept_exons.len();
255    let gene_n_exons: Vec<usize> = kept_genes
256        .iter()
257        .map(|g| groups.members[*g].len())
258        .collect();
259
260    let mut gene_counts = vec![0.0_f64; n_kept_genes * n_samples];
261    for (exon, &gene) in kept_exons.iter().zip(exon_gene.iter()) {
262        let source = &input.counts[exon * n_samples..(exon + 1) * n_samples];
263        let target = &mut gene_counts[gene * n_samples..(gene + 1) * n_samples];
264        for (acc, v) in target.iter_mut().zip(source.iter()) {
265            *acc += v.to_f64().unwrap_or(f64::NAN);
266        }
267    }
268
269    let gene_offset: Vec<f64> = input.offset.row(kept_exons[0], n_samples).to_vec(n_samples);
270    let gene_fit = glm_fit(
271        &gene_counts,
272        n_kept_genes,
273        n_samples,
274        &resolved.design,
275        n_coef,
276        &Recycled::scalar(GENE_LEVEL_DISPERSION),
277        &Recycled::by_sample(gene_offset),
278        None,
279        params.prior_count,
280    )?;
281    let betabar: Vec<f64> = (0..n_kept_genes)
282        .map(|g| gene_fit.coefficients[g * n_coef + resolved.coef])
283        .collect();
284
285    let mut counts_kept: Vec<T> = Vec::with_capacity(n_kept_exons * n_samples);
286    let mut offset_new: Vec<f64> = Vec::with_capacity(n_kept_exons * n_samples);
287    for (exon, &gene) in kept_exons.iter().zip(exon_gene.iter()) {
288        counts_kept.extend_from_slice(&input.counts[exon * n_samples..(exon + 1) * n_samples]);
289        let row = input.offset.row(*exon, n_samples);
290        for sample in 0..n_samples {
291            offset_new.push(
292                row.get(sample) + betabar[gene] * resolved.design[sample * n_coef + resolved.coef],
293            );
294        }
295    }
296
297    let dispersion = input.dispersion.subset(&kept_exons, n_samples);
298    let dispersion = match ql.and_then(|q| q.average_ql_dispersion) {
299        Some(average) => dispersion.map(|v| v / average),
300        None => dispersion,
301    };
302    let weights = input.weights.map(|w| w.subset(&kept_exons, n_samples));
303
304    let design0 = drop_column(&resolved.design, n_samples, n_coef, resolved.coef);
305    let fit0 = glm_fit(
306        &counts_kept,
307        n_kept_exons,
308        n_samples,
309        &design0,
310        n_coef - 1,
311        &dispersion,
312        &Recycled::full(offset_new, n_kept_exons, n_samples)?,
313        weights.as_ref(),
314        // edgeR leaves `prior.count` at its default, but only the unshrunk
315        // deviance is read, so shrinking would change nothing.
316        0.0,
317    )?;
318
319    let exon_df_test = fit0.df_residual as f64 - fit.df_residual as f64;
320    let exon_lr: Vec<f64> = (0..n_kept_exons)
321        .map(|i| fit0.deviance[i] - fit.deviance[kept_exons[i]])
322        .collect();
323
324    let mut gene_lr = vec![0.0_f64; n_kept_genes];
325    for (lr, &gene) in exon_lr.iter().zip(exon_gene.iter()) {
326        gene_lr[gene] += lr;
327    }
328    let gene_df_test: Vec<f64> = gene_n_exons
329        .iter()
330        .map(|n| *n as f64 * exon_df_test)
331        .collect();
332
333    let tests = match ql {
334        None => lrt_tests(&exon_lr, exon_df_test, &gene_lr, &gene_df_test)?,
335        Some(q) => ql_tests(
336            &exon_lr,
337            exon_df_test,
338            &gene_lr,
339            &gene_df_test,
340            &kept_exons,
341            &exon_gene,
342            n_kept_genes,
343            q,
344            &params,
345        )?,
346    };
347
348    let gene_simes_p: Vec<f64> = gene_n_exons
349        .par_iter()
350        .zip(gene_start.par_iter())
351        .map(|(n, start)| simes(&tests.exon_p[*start..start + n]))
352        .collect();
353
354    let mut exon_log_fc = vec![0.0_f64; n_exons];
355    let mut exon_statistic = vec![0.0_f64; n_exons];
356    let mut exon_p_value = vec![1.0_f64; n_exons];
357    for (i, &exon) in kept_exons.iter().enumerate() {
358        exon_log_fc[exon] = resolved.beta[exon] - betabar[exon_gene[i]];
359        exon_statistic[exon] = tests.exon_statistic[i];
360        exon_p_value[exon] = tests.exon_p[i];
361    }
362
363    Ok(DiffSpliceResult {
364        exon_log_fc,
365        exon_statistic,
366        exon_p_value,
367        gene_id: kept_genes.iter().map(|g| groups.ids[*g]).collect(),
368        gene_simes_p,
369        gene_f_statistic: tests.gene_statistic,
370        gene_f_p_value: tests.gene_p,
371        gene_n_exons,
372    })
373}
374
375/// Fits a [`DgeList`] of exon counts and tests it for differential usage.
376///
377/// The [`DgeList`] wrapper around [`diff_splice`], as `glm_fit_dge` is to
378/// `glm_fit`. It fits the exon-level GLM from the container's offsets and
379/// dispersion with [`DEFAULT_PRIOR_COUNT`], as `glmFit` does;
380/// `params.prior_count` only reaches the gene-level fit.
381///
382/// Likelihood ratio flavour only. For quasi-likelihood, run `glm_ql_fit` and
383/// call [`diff_splice`] with a [`DiffSpliceQl`].
384///
385/// ### Params
386///
387/// * `dge` - Exon-level counts, one row per exon, carrying a dispersion
388///   estimate
389/// * `design` - Design matrix, row-major `n_samples * n_coef`
390/// * `n_coef` - Number of coefficients, at least two
391/// * `gene_id` - Gene label per exon
392/// * `tested` - Which coefficient carries the comparison
393/// * `params` - Tuning knobs, or [`DiffSpliceParams::default`]
394///
395/// ### Returns
396///
397/// The exon and gene level tests, or [`EdgeErrors`] if the container has no
398/// dispersion, a shape disagrees, or [`diff_splice`] rejects the input.
399pub fn diff_splice_dge<T: EdgeFloat>(
400    dge: &DgeList<T>,
401    design: &[f64],
402    n_coef: usize,
403    gene_id: &[usize],
404    tested: &Tested,
405    params: Option<DiffSpliceParams>,
406) -> Result<DiffSpliceResult, EdgeErrors> {
407    let (dispersion, _) = dge.dispersion().ok_or_else(|| {
408        EdgeErrors::InvalidArgument("no dispersion has been estimated for this DgeList".to_string())
409    })?;
410    let offset = dge.offset()?;
411    let fit = glm_fit_dge(dge, design, n_coef, None)?;
412
413    let input = GlmTestInput {
414        counts: &dge.counts,
415        n_genes: dge.n_genes,
416        n_samples: dge.n_samples,
417        design,
418        n_coef,
419        dispersion: &dispersion,
420        offset: &offset,
421        weights: dge.weights.as_ref(),
422        log_cpm: None,
423    };
424    diff_splice(&input, &fit, gene_id, tested, None, params)
425}
426
427/// Identifies genes carrying splice variants.
428///
429/// Port of edgeR's `spliceVariants`. Each gene is unrolled into one row of
430/// `n_exons * n_samples` counts, exon block by exon block, and fitted with
431/// `~ exon + group + exon:group`. A likelihood ratio test on the
432/// `(n_exons - 1) * (n_groups - 1)` interaction coefficients gives one
433/// statistic per gene.
434///
435/// Genes are batched by exon count, as same-sized genes share a design.
436///
437/// Two edgeR behaviours are kept. Exons that are zero in every sample are
438/// dropped first, which can shrink or empty a gene. The fit has no offset;
439/// library sizes are common to every exon of a gene and cancel out of the
440/// interaction.
441///
442/// ### Params
443///
444/// * `counts` - Exon counts, row-major `n_exons * n_samples`
445/// * `n_exons` - Number of exons (rows)
446/// * `n_samples` - Number of samples (columns)
447/// * `gene_id` - Gene label per exon
448/// * `group` - Group label per sample, at least two distinct
449/// * `dispersion` - One shared dispersion, or one per gene in first-appearance
450///   order. edgeR's `estimateExonGenewiseDisp` is not ported
451///
452/// ### Returns
453///
454/// A [`DiffSpliceResult`] with gene-level results only: `gene_f_statistic` is
455/// the likelihood ratio, `gene_f_p_value` its chi-squared p-value, and
456/// `gene_simes_p` repeats it (`spliceVariants` has no Simes stage). Exon-level
457/// vectors are empty. Unlike [`diff_splice`], single-exon genes are kept with a
458/// zero statistic and a p-value of one, as in edgeR.
459///
460/// Or [`EdgeErrors`] if a shape disagrees, fewer than two groups are supplied,
461/// a dispersion is negative, or the unrolled design is too large for the
462/// samples.
463pub fn splice_variants<T: EdgeFloat>(
464    counts: &[T],
465    n_exons: usize,
466    n_samples: usize,
467    gene_id: &[usize],
468    group: &[usize],
469    dispersion: &[f64],
470) -> Result<DiffSpliceResult, EdgeErrors> {
471    if n_exons == 0 || n_samples == 0 {
472        return Err(EdgeErrors::EmptyCounts {
473            n_genes: n_exons,
474            n_samples,
475        });
476    }
477    if counts.len() != n_exons * n_samples {
478        return Err(EdgeErrors::LengthMismatch {
479            name: "counts",
480            expected: n_exons * n_samples,
481            got: counts.len(),
482        });
483    }
484    if gene_id.len() != n_exons {
485        return Err(EdgeErrors::LengthMismatch {
486            name: "gene_id",
487            expected: n_exons,
488            got: gene_id.len(),
489        });
490    }
491    if group.len() != n_samples {
492        return Err(EdgeErrors::LengthMismatch {
493            name: "group",
494            expected: n_samples,
495            got: group.len(),
496        });
497    }
498    if let Some(bad) = dispersion.iter().find(|d| !d.is_finite() || **d < 0.0) {
499        return Err(EdgeErrors::InvalidDispersion(*bad));
500    }
501
502    let mut levels: Vec<usize> = group.to_vec();
503    levels.sort_unstable();
504    levels.dedup();
505    let n_groups = levels.len();
506    if n_groups < 2 {
507        return Err(EdgeErrors::InvalidArgument(format!(
508            "splice_variants needs at least two groups; got {n_groups}"
509        )));
510    }
511    let group_level: Vec<usize> = group
512        .iter()
513        .map(|g| {
514            levels
515                .iter()
516                .position(|l| l == g)
517                .expect("level was taken from group")
518        })
519        .collect();
520
521    // edgeR drops all-zero exons first, which can shrink or remove a gene.
522    let nonzero: Vec<usize> = (0..n_exons)
523        .filter(|e| {
524            counts[e * n_samples..(e + 1) * n_samples]
525                .iter()
526                .any(|v| *v > T::zero())
527        })
528        .collect();
529    let kept_ids: Vec<usize> = nonzero.iter().map(|e| gene_id[*e]).collect();
530    let groups = group_by_gene(&kept_ids);
531    let n_genes = groups.ids.len();
532    if n_genes == 0 {
533        return Err(EdgeErrors::InvalidArgument(
534            "every exon is zero in every sample".to_string(),
535        ));
536    }
537    if dispersion.len() != 1 && dispersion.len() != n_genes {
538        return Err(EdgeErrors::LengthMismatch {
539            name: "dispersion",
540            expected: n_genes,
541            got: dispersion.len(),
542        });
543    }
544
545    let mut gene_statistic = vec![0.0_f64; n_genes];
546    let mut gene_p = vec![1.0_f64; n_genes];
547    let gene_n_exons: Vec<usize> = groups.members.iter().map(Vec::len).collect();
548
549    let mut sizes: Vec<usize> = gene_n_exons.clone();
550    sizes.sort_unstable();
551    sizes.dedup();
552    for n_exon in sizes {
553        // One exon: no interaction to test. edgeR still reports the gene.
554        if n_exon < 2 {
555            continue;
556        }
557        let batch: Vec<usize> = (0..n_genes)
558            .filter(|g| gene_n_exons[*g] == n_exon)
559            .collect();
560        let (statistic, p_value) = fit_exon_group_interaction(
561            counts,
562            n_samples,
563            &nonzero,
564            &groups,
565            &batch,
566            n_exon,
567            n_groups,
568            &group_level,
569            dispersion,
570        )?;
571        for (slot, &gene) in batch.iter().enumerate() {
572            gene_statistic[gene] = statistic[slot];
573            gene_p[gene] = p_value[slot];
574        }
575    }
576
577    Ok(DiffSpliceResult {
578        exon_log_fc: Vec::new(),
579        exon_statistic: Vec::new(),
580        exon_p_value: Vec::new(),
581        gene_id: groups.ids,
582        gene_simes_p: gene_p.clone(),
583        gene_f_statistic: gene_statistic,
584        gene_f_p_value: gene_p,
585        gene_n_exons,
586    })
587}
588
589////////////////////
590// Gene groupings //
591////////////////////
592
593/// Exons grouped by gene label, in first-appearance order.
594struct GeneGroups {
595    /// The distinct gene labels, ordered by where each was first seen.
596    ids: Vec<usize>,
597    /// Row indices belonging to each gene, in input order.
598    members: Vec<Vec<usize>>,
599}
600
601/// Groups row indices by their gene label.
602///
603/// First-appearance order, as `rowsum(..., reorder = FALSE)` gives.
604///
605/// ### Params
606///
607/// * `gene_id` - Gene label per row
608///
609/// ### Returns
610///
611/// The distinct labels and the rows belonging to each.
612fn group_by_gene(gene_id: &[usize]) -> GeneGroups {
613    let mut slot: FxHashMap<usize, usize> = FxHashMap::default();
614    let mut ids = Vec::new();
615    let mut members: Vec<Vec<usize>> = Vec::new();
616    for (row, id) in gene_id.iter().enumerate() {
617        let next = ids.len();
618        let g = *slot.entry(*id).or_insert_with(|| {
619            ids.push(*id);
620            members.push(Vec::new());
621            next
622        });
623        members[g].push(row);
624    }
625    GeneGroups { ids, members }
626}
627
628///////////////////////
629// Statistical tests //
630///////////////////////
631
632/// Exon and gene level statistics and p-values, over kept exons and genes.
633struct SpliceTests {
634    /// Statistic per kept exon.
635    exon_statistic: Vec<f64>,
636    /// P-value per kept exon.
637    exon_p: Vec<f64>,
638    /// Statistic per kept gene.
639    gene_statistic: Vec<f64>,
640    /// P-value per kept gene.
641    gene_p: Vec<f64>,
642}
643
644/// Chi-squared tests on the raw deviance differences.
645///
646/// The likelihood ratio flavour, used when the fit carries no quasi-likelihood
647/// summary. The gene level has as many df as it has exons.
648///
649/// ### Params
650///
651/// * `exon_lr` - Deviance difference per kept exon
652/// * `exon_df_test` - Degrees of freedom under test per exon
653/// * `gene_lr` - Summed deviance difference per kept gene
654/// * `gene_df_test` - Summed degrees of freedom per kept gene
655///
656/// ### Returns
657///
658/// The statistics and their p-values, or [`EdgeErrors`] if a tail probability
659/// is asked for at invalid degrees of freedom.
660fn lrt_tests(
661    exon_lr: &[f64],
662    exon_df_test: f64,
663    gene_lr: &[f64],
664    gene_df_test: &[f64],
665) -> Result<SpliceTests, EdgeErrors> {
666    let exon_p = exon_lr
667        .par_iter()
668        .map(|lr| chisq_sf(lr.max(0.0), exon_df_test))
669        .collect::<Result<Vec<f64>, EdgeErrors>>()?;
670    let gene_p = gene_lr
671        .par_iter()
672        .zip(gene_df_test.par_iter())
673        .map(|(lr, df)| chisq_sf(lr.max(0.0), *df))
674        .collect::<Result<Vec<f64>, EdgeErrors>>()?;
675    Ok(SpliceTests {
676        exon_statistic: exon_lr.to_vec(),
677        exon_p,
678        gene_statistic: gene_lr.to_vec(),
679        gene_p,
680    })
681}
682
683/// Moderated F tests against a gene-level squeezed dispersion.
684///
685/// The quasi-likelihood flavour. Each gene's exon deviances are pooled into one
686/// residual variance, squeezed towards a fitted prior across genes, and the
687/// deviance differences are divided by the result. Denominator df are capped at
688/// the experiment's total residual df.
689///
690/// ### Params
691///
692/// * `exon_lr` - Deviance difference per kept exon
693/// * `exon_df_test` - Degrees of freedom under test per exon
694/// * `gene_lr` - Summed deviance difference per kept gene
695/// * `gene_df_test` - Summed degrees of freedom per kept gene
696/// * `kept_exons` - Input row index of each kept exon
697/// * `exon_gene` - Gene slot of each kept exon
698/// * `n_genes` - Number of kept genes
699/// * `ql` - The quasi-likelihood quantities from the fit
700/// * `params` - Tuning knobs, for `robust`
701///
702/// ### Returns
703///
704/// The F statistics and their p-values, or [`EdgeErrors`] if the empirical Bayes
705/// fit or a tail probability fails.
706#[allow(clippy::too_many_arguments)]
707fn ql_tests(
708    exon_lr: &[f64],
709    exon_df_test: f64,
710    gene_lr: &[f64],
711    gene_df_test: &[f64],
712    kept_exons: &[usize],
713    exon_gene: &[usize],
714    n_genes: usize,
715    ql: &DiffSpliceQl<'_>,
716    params: &DiffSpliceParams,
717) -> Result<SpliceTests, EdgeErrors> {
718    let mut gene_df_residual = vec![0.0_f64; n_genes];
719    let mut gene_deviance = vec![0.0_f64; n_genes];
720    for (exon, &gene) in kept_exons.iter().zip(exon_gene.iter()) {
721        gene_df_residual[gene] += ql.df_residual[*exon];
722        gene_deviance[gene] += ql.deviance[*exon];
723    }
724    let gene_s2: Vec<f64> = gene_deviance
725        .iter()
726        .zip(gene_df_residual.iter())
727        .map(|(dev, df)| if *df > 0.0 { dev / df } else { 0.0 })
728        .collect();
729
730    let robust = params.robust.unwrap_or(ql.df_prior.len() > 1);
731    let squeezed = squeeze_var(
732        &gene_s2,
733        &gene_df_residual,
734        None,
735        Some(SqueezeVarParams {
736            robust,
737            ..Default::default()
738        }),
739    )?;
740    let cap: f64 = gene_df_residual.iter().sum();
741    let gene_df_total: Vec<f64> = (0..n_genes)
742        .map(|g| {
743            let prior = squeezed.df_prior[g.min(squeezed.df_prior.len() - 1)];
744            (gene_df_residual[g] + prior).min(cap)
745        })
746        .collect();
747    let s2_post = &squeezed.var_post;
748
749    let exon_statistic: Vec<f64> = exon_lr
750        .iter()
751        .zip(exon_gene.iter())
752        .map(|(lr, g)| lr / exon_df_test / s2_post[*g])
753        .collect();
754    let gene_statistic: Vec<f64> = (0..n_genes)
755        .map(|g| gene_lr[g] / gene_df_test[g] / s2_post[g])
756        .collect();
757
758    let mut exon_p = exon_statistic
759        .par_iter()
760        .zip(exon_gene.par_iter())
761        .map(|(f, g)| f_sf(f.max(0.0), exon_df_test, gene_df_total[*g]))
762        .collect::<Result<Vec<f64>, EdgeErrors>>()?;
763    let gene_p = gene_statistic
764        .par_iter()
765        .enumerate()
766        .map(|(g, f)| f_sf(f.max(0.0), gene_df_test[g], gene_df_total[g]))
767        .collect::<Result<Vec<f64>, EdgeErrors>>()?;
768
769    if ql.legacy_zeros {
770        for (i, g) in exon_gene.iter().enumerate() {
771            if s2_post[*g] < 1.0 {
772                exon_p[i] = exon_p[i].max(chisq_sf(exon_lr[i].max(0.0), exon_df_test)?);
773            }
774        }
775    }
776
777    Ok(SpliceTests {
778        exon_statistic,
779        exon_p,
780        gene_statistic,
781        gene_p,
782    })
783}
784
785/// Simes' combined p-value.
786///
787/// `min over k of (n * p_(k) / k)` on the sorted p-values. The `k = n` term is
788/// the largest p-value, so the result never exceeds one.
789///
790/// ### Params
791///
792/// * `p` - P-values to combine, in any order
793///
794/// ### Returns
795///
796/// The combined p-value, or one for an empty input.
797///
798/// ### References
799///
800/// Simes, Biometrika, 1986
801fn simes(p: &[f64]) -> f64 {
802    if p.is_empty() {
803        return 1.0;
804    }
805    let mut sorted = p.to_vec();
806    sorted.sort_by(f64::total_cmp);
807    let n = sorted.len() as f64;
808    sorted
809        .iter()
810        .enumerate()
811        .map(|(k, v)| v * n / (k + 1) as f64)
812        .fold(f64::INFINITY, f64::min)
813}
814
815////////////////////////////
816// spliceVariants kernels //
817////////////////////////////
818
819/// Fits and tests one batch of genes that share an exon count.
820///
821/// Genes are unrolled into rows of `n_exon * n_samples` counts (exon block by
822/// exon block) and share one design `~ exon + group + exon:group`. Dropping the
823/// interaction columns and reading the deviance difference off a chi-squared is
824/// `glmLRT` on the trailing coefficients.
825///
826/// ### Params
827///
828/// * `counts` - Exon counts, row-major `n_exons * n_samples`
829/// * `n_samples` - Number of samples
830/// * `nonzero` - Input row index of each surviving exon
831/// * `groups` - Gene grouping over the surviving exons
832/// * `batch` - Gene slots in this batch, all with `n_exon` exons
833/// * `n_exon` - Exons per gene in this batch, at least two
834/// * `n_groups` - Number of distinct group levels
835/// * `group_level` - Zero-based group level per sample
836/// * `dispersion` - One dispersion, or one per gene slot
837///
838/// ### Returns
839///
840/// The likelihood ratio and p-value per gene of the batch, or [`EdgeErrors`] if
841/// the unrolled design does not fit the samples available or a fit fails.
842#[allow(clippy::too_many_arguments)]
843fn fit_exon_group_interaction<T: EdgeFloat>(
844    counts: &[T],
845    n_samples: usize,
846    nonzero: &[usize],
847    groups: &GeneGroups,
848    batch: &[usize],
849    n_exon: usize,
850    n_groups: usize,
851    group_level: &[usize],
852    dispersion: &[f64],
853) -> Result<(Vec<f64>, Vec<f64>), EdgeErrors> {
854    let n_obs = n_exon * n_samples;
855    let n_coef = n_exon * n_groups;
856    let n_interaction = (n_exon - 1) * (n_groups - 1);
857    if n_coef >= n_obs {
858        return Err(EdgeErrors::InvalidArgument(format!(
859            "a gene with {n_exon} exons needs more than {n_groups} samples for the \
860             exon-by-group interaction; got {n_samples}"
861        )));
862    }
863
864    let mut design = vec![0.0_f64; n_obs * n_coef];
865    for exon in 0..n_exon {
866        for sample in 0..n_samples {
867            let row = &mut design[(exon * n_samples + sample) * n_coef..][..n_coef];
868            let level = group_level[sample];
869            row[0] = 1.0;
870            if exon > 0 {
871                row[exon] = 1.0;
872            }
873            if level > 0 {
874                row[n_exon - 1 + level] = 1.0;
875            }
876            if exon > 0 && level > 0 {
877                row[n_exon + n_groups - 2 + (level - 1) * (n_exon - 1) + exon] = 1.0;
878            }
879        }
880    }
881
882    let mut unrolled: Vec<T> = Vec::with_capacity(batch.len() * n_obs);
883    for &gene in batch {
884        for &member in &groups.members[gene] {
885            let exon = nonzero[member];
886            unrolled.extend_from_slice(&counts[exon * n_samples..(exon + 1) * n_samples]);
887        }
888    }
889    let disp = if dispersion.len() == 1 {
890        Recycled::scalar(dispersion[0])
891    } else {
892        Recycled::by_gene(batch.iter().map(|g| dispersion[*g]).collect())
893    };
894
895    let offset = Recycled::scalar(0.0);
896    let fit = glm_fit(
897        &unrolled,
898        batch.len(),
899        n_obs,
900        &design,
901        n_coef,
902        &disp,
903        &offset,
904        None,
905        0.0,
906    )?;
907    let input = GlmTestInput {
908        counts: &unrolled,
909        n_genes: batch.len(),
910        n_samples: n_obs,
911        design: &design,
912        n_coef,
913        dispersion: &disp,
914        offset: &offset,
915        weights: None,
916        log_cpm: None,
917    };
918    let tested = Tested::Coef((n_coef - n_interaction..n_coef).collect());
919    let test = glm_lrt(&input, &fit, &tested, None)?;
920    Ok((test.statistic, test.p_value))
921}
922
923/////////////
924// Helpers //
925/////////////
926
927/// The coefficient under test, resolved against a possibly rewritten design.
928struct ResolvedCoef {
929    /// Design the tests run against, row-major `n_samples * n_coef`. The input
930    /// design unless a contrast forced a change of basis.
931    design: Vec<f64>,
932    /// Column of `design` carrying the comparison.
933    coef: usize,
934    /// The tested effect per exon, one per input row.
935    beta: Vec<f64>,
936}
937
938/// Picks out the tested coefficient, rotating the design if it is a contrast.
939///
940/// edgeR takes only the first entry of `coef` and first column of `contrast`,
941/// with a warning. Here the truncation is silent; see [`diff_splice`].
942///
943/// ### Params
944///
945/// * `input` - The test input, for the design and its shape
946/// * `fit` - The exon-level fit, for its coefficients
947/// * `tested` - Coefficient index or contrast
948///
949/// ### Returns
950///
951/// The design, the tested column and the per-exon effect, or [`EdgeErrors`] if
952/// the index is out of range, the contrast is empty or rank deficient, or the
953/// shapes disagree.
954fn resolve_tested<T: EdgeFloat>(
955    input: &GlmTestInput<'_, T>,
956    fit: &GlmFit,
957    tested: &Tested,
958) -> Result<ResolvedCoef, EdgeErrors> {
959    let n_coef = input.n_coef;
960    match tested {
961        Tested::Coef(coefs) => {
962            let coef = *coefs.first().ok_or_else(|| {
963                EdgeErrors::InvalidArgument("no coefficient was given to test".to_string())
964            })?;
965            if coef >= n_coef {
966                return Err(EdgeErrors::CoefOutOfRange {
967                    index: coef,
968                    n_coef,
969                });
970            }
971            let beta = (0..input.n_genes)
972                .map(|exon| fit.coefficients[exon * n_coef + coef])
973                .collect();
974            Ok(ResolvedCoef {
975                design: input.design.to_vec(),
976                coef,
977                beta,
978            })
979        }
980        Tested::Contrast {
981            values,
982            n_contrasts,
983        } => {
984            if *n_contrasts == 0 {
985                return Err(EdgeErrors::InvalidArgument(
986                    "no contrast was given to test".to_string(),
987                ));
988            }
989            if values.len() != n_coef * n_contrasts {
990                return Err(EdgeErrors::LengthMismatch {
991                    name: "contrast",
992                    expected: n_coef * n_contrasts,
993                    got: values.len(),
994                });
995            }
996            let first = &values[..n_coef];
997            let reform = contrast_as_coef(input.design, input.n_samples, n_coef, first, 1, true)?;
998            let beta = (0..input.n_genes)
999                .map(|exon| {
1000                    let row = &fit.coefficients[exon * n_coef..(exon + 1) * n_coef];
1001                    row.iter().zip(first.iter()).map(|(b, c)| b * c).sum()
1002                })
1003                .collect();
1004            Ok(ResolvedCoef {
1005                design: reform.design,
1006                coef: reform.coef[0],
1007                beta,
1008            })
1009        }
1010    }
1011}
1012
1013/// Checks the shapes [`diff_splice`] depends on.
1014///
1015/// ### Params
1016///
1017/// * `input` - The test input
1018/// * `fit` - The exon-level fit
1019/// * `gene_id` - Gene label per exon
1020/// * `ql` - Quasi-likelihood quantities, if any
1021/// * `params` - Tuning knobs, for `prior_count`
1022///
1023/// ### Returns
1024///
1025/// `Ok(())`, or the first [`EdgeErrors`] the shapes trip.
1026fn validate<T: EdgeFloat>(
1027    input: &GlmTestInput<'_, T>,
1028    fit: &GlmFit,
1029    gene_id: &[usize],
1030    ql: Option<&DiffSpliceQl<'_>>,
1031    params: &DiffSpliceParams,
1032) -> Result<(), EdgeErrors> {
1033    let (n_exons, n_samples, n_coef) = (input.n_genes, input.n_samples, input.n_coef);
1034    if n_exons == 0 || n_samples == 0 {
1035        return Err(EdgeErrors::EmptyCounts {
1036            n_genes: n_exons,
1037            n_samples,
1038        });
1039    }
1040    if n_coef < 2 {
1041        return Err(EdgeErrors::InvalidArgument(format!(
1042            "diff_splice needs a design with at least two columns, usually an intercept and the \
1043             comparison; got {n_coef}"
1044        )));
1045    }
1046    if input.counts.len() != n_exons * n_samples {
1047        return Err(EdgeErrors::LengthMismatch {
1048            name: "counts",
1049            expected: n_exons * n_samples,
1050            got: input.counts.len(),
1051        });
1052    }
1053    if input.design.len() != n_samples * n_coef {
1054        return Err(EdgeErrors::LengthMismatch {
1055            name: "design",
1056            expected: n_samples * n_coef,
1057            got: input.design.len(),
1058        });
1059    }
1060    if gene_id.len() != n_exons {
1061        return Err(EdgeErrors::LengthMismatch {
1062            name: "gene_id",
1063            expected: n_exons,
1064            got: gene_id.len(),
1065        });
1066    }
1067    if fit.coefficients.len() != n_exons * n_coef {
1068        return Err(EdgeErrors::LengthMismatch {
1069            name: "fit.coefficients",
1070            expected: n_exons * n_coef,
1071            got: fit.coefficients.len(),
1072        });
1073    }
1074    if fit.deviance.len() != n_exons {
1075        return Err(EdgeErrors::LengthMismatch {
1076            name: "fit.deviance",
1077            expected: n_exons,
1078            got: fit.deviance.len(),
1079        });
1080    }
1081    if params.prior_count < 0.0 || params.prior_count.is_nan() {
1082        return Err(EdgeErrors::InvalidArgument(format!(
1083            "prior_count must be non-negative, got {}",
1084            params.prior_count
1085        )));
1086    }
1087    input.dispersion.validate(n_exons, n_samples)?;
1088    input.offset.validate(n_exons, n_samples)?;
1089    if let Some(w) = input.weights {
1090        w.validate(n_exons, n_samples)?;
1091    }
1092    if let Some(q) = ql {
1093        if q.df_residual.len() != n_exons {
1094            return Err(EdgeErrors::LengthMismatch {
1095                name: "ql.df_residual",
1096                expected: n_exons,
1097                got: q.df_residual.len(),
1098            });
1099        }
1100        if q.deviance.len() != n_exons {
1101            return Err(EdgeErrors::LengthMismatch {
1102                name: "ql.deviance",
1103                expected: n_exons,
1104                got: q.deviance.len(),
1105            });
1106        }
1107        if q.df_prior.is_empty() {
1108            return Err(EdgeErrors::InvalidArgument(
1109                "ql.df_prior is empty".to_string(),
1110            ));
1111        }
1112    }
1113    Ok(())
1114}
1115
1116/// Drops one column from a row-major matrix.
1117///
1118/// ### Params
1119///
1120/// * `matrix` - Row-major values, `n_rows * n_cols`
1121/// * `n_rows` - Number of rows
1122/// * `n_cols` - Number of columns
1123/// * `drop` - Column to remove
1124///
1125/// ### Returns
1126///
1127/// A fresh `n_rows * (n_cols - 1)` matrix, row-major.
1128fn drop_column(matrix: &[f64], n_rows: usize, n_cols: usize, drop: usize) -> Vec<f64> {
1129    let mut out = Vec::with_capacity(n_rows * (n_cols - 1));
1130    for row in matrix.chunks_exact(n_cols) {
1131        for (col, v) in row.iter().enumerate() {
1132            if col != drop {
1133                out.push(*v);
1134            }
1135        }
1136    }
1137    out
1138}
1139
1140///////////
1141// Tests //
1142///////////
1143
1144#[cfg(test)]
1145mod tests {
1146    use super::*;
1147    use approx::assert_relative_eq;
1148
1149    /// Two genes of three exons each, the fixture from the gate command.
1150    fn gate() -> (Vec<f64>, Vec<usize>, Vec<f64>) {
1151        let counts = vec![
1152            8.0, 16.0, 32.0, 64.0, 128.0, 256.0, //
1153            4.0, 4.0, 8.0, 8.0, 16.0, 16.0, //
1154            64.0, 64.0, 64.0, 128.0, 128.0, 128.0, //
1155            12.0, 20.0, 28.0, 40.0, 56.0, 72.0, //
1156            100.0, 110.0, 90.0, 220.0, 200.0, 240.0, //
1157            6.0, 6.0, 6.0, 12.0, 12.0, 12.0, //
1158        ];
1159        let gene_id = vec![1, 1, 1, 2, 2, 2];
1160        let design = vec![
1161            1.0, 0.0, //
1162            1.0, 0.0, //
1163            1.0, 0.0, //
1164            1.0, 1.0, //
1165            1.0, 1.0, //
1166            1.0, 1.0, //
1167        ];
1168        (counts, gene_id, design)
1169    }
1170
1171    /// Five genes of one to four exons, including an exon that is zero
1172    /// everywhere. Gene 2 has a single exon and must be dropped.
1173    fn mixed() -> (Vec<f64>, Vec<usize>, Vec<f64>) {
1174        let counts = vec![
1175            8.0, 16.0, 32.0, 64.0, 128.0, 256.0, //
1176            4.0, 4.0, 8.0, 8.0, 16.0, 16.0, //
1177            64.0, 64.0, 64.0, 128.0, 128.0, 128.0, //
1178            32.0, 16.0, 8.0, 4.0, 2.0, 1.0, //
1179            12.0, 20.0, 28.0, 40.0, 56.0, 72.0, //
1180            100.0, 110.0, 90.0, 220.0, 200.0, 240.0, //
1181            0.0, 0.0, 0.0, 0.0, 0.0, 0.0, //
1182            6.0, 6.0, 6.0, 12.0, 12.0, 12.0, //
1183            256.0, 128.0, 192.0, 64.0, 96.0, 32.0, //
1184            8.0, 8.0, 8.0, 8.0, 8.0, 8.0, //
1185            20.0, 24.0, 16.0, 48.0, 40.0, 44.0, //
1186            512.0, 256.0, 128.0, 64.0, 32.0, 16.0, //
1187            96.0, 80.0, 64.0, 48.0, 32.0, 16.0, //
1188        ];
1189        let gene_id = vec![1, 1, 1, 1, 2, 3, 3, 3, 4, 4, 5, 5, 5];
1190        let design = vec![
1191            1.0, 0.0, //
1192            1.0, 0.0, //
1193            1.0, 0.0, //
1194            1.0, 1.0, //
1195            1.0, 1.0, //
1196            1.0, 1.0, //
1197        ];
1198        (counts, gene_id, design)
1199    }
1200
1201    /// Library-size offsets, as `glmFit` derives them when none are supplied.
1202    fn offsets_from(counts: &[f64], n_exons: usize, n_samples: usize) -> Recycled<f64> {
1203        let mut lib = vec![0.0_f64; n_samples];
1204        for row in counts.chunks_exact(n_samples) {
1205            for (acc, v) in lib.iter_mut().zip(row.iter()) {
1206                *acc += v;
1207            }
1208        }
1209        assert_eq!(counts.len(), n_exons * n_samples);
1210        Recycled::by_sample(lib.iter().map(|v| v.ln()).collect())
1211    }
1212
1213    /// Fits the exon level GLM the way `glmFit(y, X, dispersion = 0.1)` does.
1214    fn exon_fit(counts: &[f64], n_exons: usize, design: &[f64]) -> (GlmFit, Recycled<f64>) {
1215        let offset = offsets_from(counts, n_exons, 6);
1216        let fit = glm_fit(
1217            counts,
1218            n_exons,
1219            6,
1220            design,
1221            2,
1222            &Recycled::scalar(0.1),
1223            &offset,
1224            None,
1225            DEFAULT_PRIOR_COUNT,
1226        )
1227        .unwrap();
1228        (fit, offset)
1229    }
1230
1231    /// Exon and gene level likelihood ratio tests, against edgeR 4.8.2:
1232    /// ```r
1233    /// y <- matrix(c(8,16,32,64,128,256, 4,4,8,8,16,16, 64,64,64,128,128,128,
1234    ///               12,20,28,40,56,72, 100,110,90,220,200,240, 6,6,6,12,12,12),
1235    ///             nrow = 6, byrow = TRUE)
1236    /// gid <- c(1,1,1,2,2,2)
1237    /// X <- cbind(1, c(0,0,0,1,1,1))
1238    /// f <- glmFit(y, X, dispersion = 0.1)
1239    /// s <- diffSpliceDGE(f, geneid = gid)
1240    /// ```
1241    #[test]
1242    fn test_matches_edger_lrt_on_the_gate_fixture() {
1243        let (counts, gene_id, design) = gate();
1244        let (fit, offset) = exon_fit(&counts, 6, &design);
1245        let input = GlmTestInput {
1246            counts: &counts,
1247            n_genes: 6,
1248            n_samples: 6,
1249            design: &design,
1250            n_coef: 2,
1251            dispersion: &Recycled::scalar(0.1),
1252            offset: &offset,
1253            weights: None,
1254            log_cpm: None,
1255        };
1256        let out = diff_splice(&input, &fit, &gene_id, &Tested::Coef(vec![1]), None, None).unwrap();
1257
1258        // s$coefficients
1259        let expected_log_fc = [
1260            0.855_174_395_193_151_3,
1261            -0.261_843_130_654_270_2,
1262            -0.461_416_768_452_753_1,
1263            0.192_177_299_143_341_5,
1264            -0.035_676_657_669_298_6,
1265            -0.128_554_161_416_470_9,
1266        ];
1267        // s$exon.LR
1268        let expected_lr = [
1269            8.436_569_219_765_81,
1270            0.440_940_993_487_607_8,
1271            2.839_571_646_837_012_7,
1272            0.413_720_073_978_469,
1273            0.017_921_690_140_715_3,
1274            0.115_727_330_674_815_6,
1275        ];
1276        // s$exon.p.value
1277        let expected_p = [
1278            0.003_677_493_961_314_914_4,
1279            0.506_668_627_239_259_9,
1280            0.091_968_728_075_895_53,
1281            0.520_087_356_281_412_4,
1282            0.893_503_857_408_516_5,
1283            0.733_715_557_310_116_2,
1284        ];
1285        for i in 0..6 {
1286            assert_relative_eq!(out.exon_log_fc[i], expected_log_fc[i], max_relative = 1e-9);
1287            assert_relative_eq!(out.exon_statistic[i], expected_lr[i], max_relative = 1e-9);
1288            assert_relative_eq!(out.exon_p_value[i], expected_p[i], max_relative = 1e-9);
1289        }
1290
1291        assert_eq!(out.gene_id, vec![1, 2]);
1292        assert_eq!(out.gene_n_exons, vec![3, 3]);
1293        // s$gene.LR, s$gene.p.value, s$gene.Simes.p.value
1294        assert_relative_eq!(
1295            out.gene_f_statistic[0],
1296            11.717_081_860_090_431,
1297            max_relative = 1e-9
1298        );
1299        assert_relative_eq!(
1300            out.gene_f_statistic[1],
1301            0.547_369_094_793_999_9,
1302            max_relative = 1e-9
1303        );
1304        assert_relative_eq!(
1305            out.gene_f_p_value[0],
1306            0.008_417_910_555_119_631,
1307            max_relative = 1e-9
1308        );
1309        assert_relative_eq!(
1310            out.gene_f_p_value[1],
1311            0.908_367_971_898_078,
1312            max_relative = 1e-9
1313        );
1314        assert_relative_eq!(
1315            out.gene_simes_p[0],
1316            0.011_032_481_883_944_744,
1317            max_relative = 1e-9
1318        );
1319        assert_relative_eq!(
1320            out.gene_simes_p[1],
1321            0.893_503_857_408_516_5,
1322            max_relative = 1e-9
1323        );
1324    }
1325
1326    /// Genes of one, two, three and four exons, one of which is zero in every
1327    /// sample. Against edgeR 4.8.2:
1328    /// ```r
1329    /// y <- matrix(c(8,16,32,64,128,256, 4,4,8,8,16,16, 64,64,64,128,128,128,
1330    ///               32,16,8,4,2,1, 12,20,28,40,56,72, 100,110,90,220,200,240,
1331    ///               0,0,0,0,0,0, 6,6,6,12,12,12, 256,128,192,64,96,32,
1332    ///               8,8,8,8,8,8, 20,24,16,48,40,44, 512,256,128,64,32,16,
1333    ///               96,80,64,48,32,16), nrow = 13, byrow = TRUE)
1334    /// gid <- c(1,1,1,1,2,3,3,3,4,4,5,5,5)
1335    /// X <- cbind(1, c(0,0,0,1,1,1))
1336    /// s <- diffSpliceDGE(glmFit(y, X, dispersion = 0.1), geneid = gid)
1337    /// ```
1338    #[test]
1339    fn test_matches_edger_lrt_with_mixed_exon_counts() {
1340        let (counts, gene_id, design) = mixed();
1341        let (fit, offset) = exon_fit(&counts, 13, &design);
1342        let input = GlmTestInput {
1343            counts: &counts,
1344            n_genes: 13,
1345            n_samples: 6,
1346            design: &design,
1347            n_coef: 2,
1348            dispersion: &Recycled::scalar(0.1),
1349            offset: &offset,
1350            weights: None,
1351            log_cpm: None,
1352        };
1353        let out = diff_splice(&input, &fit, &gene_id, &Tested::Coef(vec![1]), None, None).unwrap();
1354
1355        // The single-exon gene is dropped, so its row carries the neutral values.
1356        assert_eq!(out.gene_id, vec![1, 3, 4, 5]);
1357        assert_eq!(out.gene_n_exons, vec![4, 3, 2, 3]);
1358        assert_eq!(out.exon_log_fc[4], 0.0);
1359        assert_eq!(out.exon_statistic[4], 0.0);
1360        assert_eq!(out.exon_p_value[4], 1.0);
1361
1362        // s$coefficients, over the twelve kept exons in input order
1363        let expected_log_fc = [
1364            0.986_261_373_785_026_8,
1365            -0.092_813_816_117_244_93,
1366            -0.299_573_258_165_403_7,
1367            -2.932_713_900_334_851_7,
1368            0.002_284_551_340_094_198_6,
1369            -0.822_162_460_092_049_4,
1370            -0.078_425_809_463_148_25,
1371            -0.072_451_134_208_873_84,
1372            0.991_832_771_986_186_4,
1373            1.951_633_344_827_402_4,
1374            -0.785_350_396_075_128_2,
1375            0.286_727_193_468_867,
1376        ];
1377        // s$exon.LR
1378        let expected_lr = [
1379            11.203_352_118_219_136,
1380            0.039_045_494_218_443_72,
1381            1.189_460_502_071_776_1,
1382            49.591_886_657_154_48,
1383            0.000_124_411_095_185_239_52,
1384            0.0,
1385            0.029_691_603_087_216_08,
1386            0.073_511_269_031_998_34,
1387            6.456_369_674_304_806,
1388            39.738_828_764_140_41,
1389            7.975_958_887_749_272,
1390            0.991_332_488_893_542_4,
1391        ];
1392        // s$exon.p.value
1393        let expected_p = [
1394            0.000_816_497_019_046_571_5,
1395            0.843_358_561_026_85,
1396            0.275_438_406_419_161,
1397            1.892_949_108_888_925e-12,
1398            0.991_100_602_344_373_6,
1399            1.0,
1400            0.863_191_845_368_248_2,
1401            0.786_291_343_598_799_3,
1402            0.011_055_528_859_942_796,
1403            2.902_963_175_866_408e-10,
1404            0.004_740_264_033_567_157,
1405            0.319_416_920_511_383_43,
1406        ];
1407        let kept: Vec<usize> = (0..13).filter(|i| *i != 4).collect();
1408        for (slot, &exon) in kept.iter().enumerate() {
1409            assert_relative_eq!(
1410                out.exon_log_fc[exon],
1411                expected_log_fc[slot],
1412                max_relative = 1e-9,
1413                epsilon = 1e-12
1414            );
1415            assert_relative_eq!(
1416                out.exon_statistic[exon],
1417                expected_lr[slot],
1418                max_relative = 1e-9,
1419                epsilon = 1e-12
1420            );
1421            assert_relative_eq!(
1422                out.exon_p_value[exon],
1423                expected_p[slot],
1424                max_relative = 1e-9
1425            );
1426        }
1427
1428        // s$gene.LR
1429        let expected_gene_lr = [
1430            62.023_744_771_663_84,
1431            0.029_816_014_182_401_318,
1432            6.529_880_943_336_805,
1433            48.706_120_140_783_22,
1434        ];
1435        // s$gene.p.value
1436        let expected_gene_p = [
1437            1.088_995_352_370_079_7e-12,
1438            0.998_642_900_101_906_2,
1439            0.038_199_208_951_176_705,
1440            1.506_569_551_839_668e-10,
1441        ];
1442        // s$gene.Simes.p.value
1443        let expected_simes = [
1444            7.571_796_435_555_7e-12,
1445            1.0,
1446            0.022_111_057_719_885_592,
1447            8.708_889_527_599_224e-10,
1448        ];
1449        for g in 0..4 {
1450            assert_relative_eq!(
1451                out.gene_f_statistic[g],
1452                expected_gene_lr[g],
1453                max_relative = 1e-9
1454            );
1455            assert_relative_eq!(
1456                out.gene_f_p_value[g],
1457                expected_gene_p[g],
1458                max_relative = 1e-9
1459            );
1460            assert_relative_eq!(out.gene_simes_p[g], expected_simes[g], max_relative = 1e-9);
1461        }
1462    }
1463
1464    /// The quasi-likelihood path, against edgeR 4.8.2:
1465    /// ```r
1466    /// qf <- glmQLFit(y, X, dispersion = 0.1, legacy = FALSE)   # y, X as above
1467    /// s <- diffSpliceDGE(qf, geneid = gid)
1468    /// ```
1469    ///
1470    /// The fit is edgeR's, embedded as literals so only `diff_splice` is
1471    /// exercised.
1472    #[test]
1473    fn test_matches_edger_quasi_likelihood_f_tests() {
1474        let (counts, gene_id, design) = mixed();
1475        let offset = offsets_from(&counts, 13, 6);
1476
1477        // as.vector(t(qf$coefficients))
1478        let coefficients = vec![
1479            -3.716_649_123_803_044,
1480            2.053_338_438_767_273_3,
1481            -5.004_790_281_784_431,
1482            0.958_599_359_856_350_5,
1483            -2.521_384_664_641_309,
1484            0.736_712_308_560_846_2,
1485            -3.818_356_521_647_034_5,
1486            -1.922_703_330_975_371_8,
1487            -3.662_138_872_007_650_6,
1488            1.039_972_007_661_760_2,
1489            -2.075_371_926_749_308,
1490            0.829_195_942_258_679_3,
1491            -8.762_906_779_045_517,
1492            0.0,
1493            -4.898_543_552_754_313,
1494            0.753_138_951_659_528_8,
1495            -1.453_993_800_252_147,
1496            -1.010_073_952_288_818_5,
1497            -4.613_303_129_296_377,
1498            0.066_946_339_544_628_59,
1499            -3.698_761_496_409_288_5,
1500            0.848_147_053_835_922_5,
1501            -1.086_383_432_125_770_6,
1502            -1.908_243_414_438_834,
1503            -2.323_859_444_983_016,
1504            -0.829_704_616_425_026_7,
1505        ];
1506        // qf$deviance
1507        let deviance = vec![
1508            32.934_761_083_769_92,
1509            5.474_240_344_388_582_5,
1510            3.263_241_513_475_087_6,
1511            5.470_865_795_828_582_5,
1512            11.857_671_991_595_268,
1513            3.339_511_158_808_827_8,
1514            0.0,
1515            0.950_343_670_388_99,
1516            15.350_643_930_570_67,
1517            1.123_733_784_713_515_8,
1518            2.832_718_144_252_497_4,
1519            24.339_069_453_856_304,
1520            10.860_457_084_684_125,
1521        ];
1522        // qf$df.residual.adj
1523        let df_residual_adj = [
1524            3.949_399_184_578_337,
1525            4.429_910_119_524_08,
1526            3.995_636_528_129_935_5,
1527            6.215_119_099_483_442,
1528            3.947_839_985_398_613_5,
1529            3.999_088_419_001_173,
1530            0.0,
1531            4.298_601_889_799_675,
1532            3.996_644_613_146_141,
1533            4.160_749_445_488_15,
1534            3.941_931_767_768_654,
1535            3.987_982_672_430_048,
1536            3.979_648_238_150_599,
1537        ];
1538        // qf$deviance.adj
1539        let deviance_adj = [
1540            31.681_571_682_117_89,
1541            5.716_898_207_948_569,
1542            3.199_241_360_732_763_4,
1543            7.325_504_268_111_918,
1544            11.299_290_927_664_186,
1545            3.281_195_035_994_136,
1546            0.0,
1547            0.943_989_863_873_622_4,
1548            15.053_574_377_441_105,
1549            1.055_510_220_953_673_5,
1550            2.703_138_273_427_556_6,
1551            23.743_938_823_095_38,
1552            10.517_433_786_052_315,
1553        ];
1554        // qf$df.prior, qf$average.ql.dispersion
1555        let df_prior = [50.431_634_778_955_26];
1556        let average_ql_dispersion = 2.517_465_302_235_576;
1557
1558        let fit = GlmFit {
1559            coefficients,
1560            unshrunk_coefficients: None,
1561            fitted: Vec::new(),
1562            deviance,
1563            df_residual: 4,
1564            method: crate::glm::fit::FitMethod::OneWay,
1565        };
1566        let ql = DiffSpliceQl {
1567            df_prior: &df_prior,
1568            df_residual: &df_residual_adj,
1569            deviance: &deviance_adj,
1570            legacy_zeros: false,
1571            average_ql_dispersion: Some(average_ql_dispersion),
1572        };
1573        let input = GlmTestInput {
1574            counts: &counts,
1575            n_genes: 13,
1576            n_samples: 6,
1577            design: &design,
1578            n_coef: 2,
1579            // glmQLFit stores the undivided dispersion; diff_splice divides it.
1580            dispersion: &Recycled::scalar(0.1),
1581            offset: &offset,
1582            weights: None,
1583            log_cpm: None,
1584        };
1585        let out = diff_splice(
1586            &input,
1587            &fit,
1588            &gene_id,
1589            &Tested::Coef(vec![1]),
1590            Some(&ql),
1591            None,
1592        )
1593        .unwrap();
1594
1595        // s$exon.F over the twelve kept exons
1596        let expected_f = [
1597            10.923_983_891_986_724,
1598            0.012_553_476_455_043_623,
1599            1.147_597_224_575_699_3,
1600            33.801_175_852_216_82,
1601            0.000_892_201_161_999_136_9,
1602            0.0,
1603            0.013_982_948_088_757_496,
1604            0.080_179_046_492_054_87,
1605            4.186_641_136_888_011,
1606            35.647_046_431_592_5,
1607            8.176_517_864_533_706,
1608            0.884_633_969_063_336_8,
1609        ];
1610        // s$exon.p.value
1611        let expected_p = [
1612            0.001_823_606_690_634_109_5,
1613            0.911_267_482_829_818_5,
1614            0.289_528_269_680_133_8,
1615            5.158_549_168_775_326e-7,
1616            0.976_297_542_709_692_6,
1617            1.0,
1618            0.906_374_380_415_774_7,
1619            0.778_299_597_202_179_1,
1620            0.046_366_642_339_368_824,
1621            2.992_427_861_639_236_6e-7,
1622            0.006_311_974_788_731_110_5,
1623            0.351_749_366_873_619_46,
1624        ];
1625        // s$coefficients
1626        let expected_log_fc = [
1627            1.024_069_012_156_488_6,
1628            -0.070_670_066_754_434_21,
1629            -0.292_557_118_049_938_45,
1630            -2.951_972_757_586_156_5,
1631            0.007_033_482_166_629_912,
1632            -0.822_162_460_092_049_4,
1633            -0.069_023_508_432_520_59,
1634            -0.074_627_228_010_850_64,
1635            1.002_393_063_822_596_4,
1636            1.960_274_530_132_001,
1637            -0.796_115_938_142_755_4,
1638            0.282_422_859_871_052,
1639        ];
1640        let kept: Vec<usize> = (0..13).filter(|i| *i != 4).collect();
1641        for (slot, &exon) in kept.iter().enumerate() {
1642            assert_relative_eq!(
1643                out.exon_log_fc[exon],
1644                expected_log_fc[slot],
1645                max_relative = 1e-9,
1646                epsilon = 1e-12
1647            );
1648            assert_relative_eq!(
1649                out.exon_statistic[exon],
1650                expected_f[slot],
1651                max_relative = 1e-9,
1652                epsilon = 1e-12
1653            );
1654            assert_relative_eq!(
1655                out.exon_p_value[exon],
1656                expected_p[slot],
1657                max_relative = 1e-9
1658            );
1659        }
1660
1661        // s$gene.F, s$gene.p.value, s$gene.Simes.p.value
1662        let expected_gene_f = [
1663            11.471_327_611_308_572,
1664            0.004_958_383_083_585_545,
1665            2.133_410_091_690_033,
1666            14.902_732_755_063_182,
1667        ];
1668        let expected_gene_p = [
1669            1.412_135_847_820_031_8e-6,
1670            0.999_512_145_784_596_1,
1671            0.129_771_944_348_671_37,
1672            5.987_503_169_646_61e-7,
1673        ];
1674        let expected_simes = [
1675            2.063_419_667_510_130_6e-6,
1676            1.0,
1677            0.092_733_284_678_737_65,
1678            8.977_283_584_917_71e-7,
1679        ];
1680        for g in 0..4 {
1681            assert_relative_eq!(
1682                out.gene_f_statistic[g],
1683                expected_gene_f[g],
1684                max_relative = 1e-9
1685            );
1686            assert_relative_eq!(
1687                out.gene_f_p_value[g],
1688                expected_gene_p[g],
1689                max_relative = 1e-9
1690            );
1691            assert_relative_eq!(out.gene_simes_p[g], expected_simes[g], max_relative = 1e-9);
1692        }
1693    }
1694
1695    /// A contrast picking out the same column reproduces the coefficient path.
1696    #[test]
1697    fn test_contrast_reproduces_the_coefficient_path() {
1698        let (counts, gene_id, design) = gate();
1699        let (fit, offset) = exon_fit(&counts, 6, &design);
1700        let input = GlmTestInput {
1701            counts: &counts,
1702            n_genes: 6,
1703            n_samples: 6,
1704            design: &design,
1705            n_coef: 2,
1706            dispersion: &Recycled::scalar(0.1),
1707            offset: &offset,
1708            weights: None,
1709            log_cpm: None,
1710        };
1711        let by_coef =
1712            diff_splice(&input, &fit, &gene_id, &Tested::Coef(vec![1]), None, None).unwrap();
1713        let by_contrast = diff_splice(
1714            &input,
1715            &fit,
1716            &gene_id,
1717            &Tested::Contrast {
1718                values: vec![0.0, 1.0],
1719                n_contrasts: 1,
1720            },
1721            None,
1722            None,
1723        )
1724        .unwrap();
1725
1726        for i in 0..6 {
1727            assert_relative_eq!(
1728                by_contrast.exon_p_value[i],
1729                by_coef.exon_p_value[i],
1730                max_relative = 1e-9
1731            );
1732            assert_relative_eq!(
1733                by_contrast.exon_log_fc[i],
1734                by_coef.exon_log_fc[i],
1735                max_relative = 1e-9,
1736                epsilon = 1e-12
1737            );
1738        }
1739    }
1740
1741    /// Genes are grouped by label, so shuffling exon rows permutes the answers.
1742    #[test]
1743    fn test_gene_grouping_is_independent_of_row_order() {
1744        let (counts, gene_id, design) = gate();
1745        let (fit, offset) = exon_fit(&counts, 6, &design);
1746        let input = GlmTestInput {
1747            counts: &counts,
1748            n_genes: 6,
1749            n_samples: 6,
1750            design: &design,
1751            n_coef: 2,
1752            dispersion: &Recycled::scalar(0.1),
1753            offset: &offset,
1754            weights: None,
1755            log_cpm: None,
1756        };
1757        let sorted =
1758            diff_splice(&input, &fit, &gene_id, &Tested::Coef(vec![1]), None, None).unwrap();
1759
1760        // Interleave the two genes: 1, 2, 1, 2, 1, 2.
1761        let order = [0_usize, 3, 1, 4, 2, 5];
1762        let shuffled_counts: Vec<f64> = order
1763            .iter()
1764            .flat_map(|r| counts[r * 6..(r + 1) * 6].iter().copied())
1765            .collect();
1766        let shuffled_ids: Vec<usize> = order.iter().map(|r| gene_id[*r]).collect();
1767        let (shuffled_fit, shuffled_offset) = exon_fit(&shuffled_counts, 6, &design);
1768        let shuffled_input = GlmTestInput {
1769            counts: &shuffled_counts,
1770            n_genes: 6,
1771            n_samples: 6,
1772            design: &design,
1773            n_coef: 2,
1774            dispersion: &Recycled::scalar(0.1),
1775            offset: &shuffled_offset,
1776            weights: None,
1777            log_cpm: None,
1778        };
1779        let shuffled = diff_splice(
1780            &shuffled_input,
1781            &shuffled_fit,
1782            &shuffled_ids,
1783            &Tested::Coef(vec![1]),
1784            None,
1785            None,
1786        )
1787        .unwrap();
1788
1789        assert_eq!(shuffled.gene_id, sorted.gene_id);
1790        for g in 0..2 {
1791            assert_relative_eq!(
1792                shuffled.gene_simes_p[g],
1793                sorted.gene_simes_p[g],
1794                max_relative = 1e-9
1795            );
1796        }
1797        for (slot, &row) in order.iter().enumerate() {
1798            assert_relative_eq!(
1799                shuffled.exon_p_value[slot],
1800                sorted.exon_p_value[row],
1801                max_relative = 1e-9
1802            );
1803        }
1804    }
1805
1806    /// The `DgeList` wrapper reproduces the explicit call.
1807    #[test]
1808    fn test_dge_wrapper_matches_the_explicit_call() {
1809        let (counts, gene_id, design) = gate();
1810        let (fit, offset) = exon_fit(&counts, 6, &design);
1811        let input = GlmTestInput {
1812            counts: &counts,
1813            n_genes: 6,
1814            n_samples: 6,
1815            design: &design,
1816            n_coef: 2,
1817            dispersion: &Recycled::scalar(0.1),
1818            offset: &offset,
1819            weights: None,
1820            log_cpm: None,
1821        };
1822        let direct =
1823            diff_splice(&input, &fit, &gene_id, &Tested::Coef(vec![1]), None, None).unwrap();
1824
1825        let mut dge = DgeList::new(counts.clone(), 6, 6, None).unwrap();
1826        dge.common_dispersion = Some(0.1);
1827        let wrapped =
1828            diff_splice_dge(&dge, &design, 2, &gene_id, &Tested::Coef(vec![1]), None).unwrap();
1829
1830        for i in 0..6 {
1831            assert_relative_eq!(
1832                wrapped.exon_p_value[i],
1833                direct.exon_p_value[i],
1834                max_relative = 1e-9
1835            );
1836            assert_relative_eq!(
1837                wrapped.exon_log_fc[i],
1838                direct.exon_log_fc[i],
1839                max_relative = 1e-9
1840            );
1841        }
1842    }
1843
1844    /// `spliceVariants` against edgeR 4.8.2, on the mixed fixture:
1845    /// ```r
1846    /// sv <- spliceVariants(y, geneID = gid, dispersion = 0.1,
1847    ///                      group = factor(c(1,1,1,2,2,2)))
1848    /// ```
1849    #[test]
1850    fn test_matches_edger_splice_variants() {
1851        let (counts, gene_id, _) = mixed();
1852        let group = vec![1, 1, 1, 2, 2, 2];
1853        let out = splice_variants(&counts, 13, 6, &gene_id, &group, &[0.1]).unwrap();
1854
1855        assert_eq!(out.gene_id, vec![1, 2, 3, 4, 5]);
1856        // The all-zero exon is dropped, so gene 3 arrives with two exons.
1857        assert_eq!(out.gene_n_exons, vec![4, 1, 2, 2, 3]);
1858        assert!(out.exon_p_value.is_empty());
1859
1860        // sv$table$LR
1861        let expected_lr = [
1862            64.964_266_458_512_41,
1863            0.0,
1864            0.040_945_652_052_297_28,
1865            5.370_218_472_941_118,
1866            47.582_028_134_328_2,
1867        ];
1868        // sv$table$PValue
1869        let expected_p = [
1870            5.105_088_859_960_058e-14,
1871            1.0,
1872            0.839_642_863_748_024_1,
1873            0.020_483_411_823_205_97,
1874            4.652_580_238_805_525e-11,
1875        ];
1876        // The full model is a one-way layout (closed form), but the null model
1877        // goes through the damped iteration. The LR is a difference of two
1878        // deviances near 10^2 and inherits the fitter's convergence tolerance,
1879        // so 1e-6. The rest of this module agrees with edgeR to 1e-9.
1880        for g in 0..5 {
1881            assert_relative_eq!(
1882                out.gene_f_statistic[g],
1883                expected_lr[g],
1884                max_relative = 1e-6,
1885                epsilon = 1e-12
1886            );
1887            assert_relative_eq!(out.gene_f_p_value[g], expected_p[g], max_relative = 1e-6);
1888            assert_relative_eq!(out.gene_simes_p[g], expected_p[g], max_relative = 1e-6);
1889        }
1890    }
1891
1892    /// A per-gene dispersion vector is accepted and moves the answer.
1893    #[test]
1894    fn test_splice_variants_accepts_a_genewise_dispersion() {
1895        let (counts, gene_id, _) = mixed();
1896        let group = vec![1, 1, 1, 2, 2, 2];
1897        let common = splice_variants(&counts, 13, 6, &gene_id, &group, &[0.1]).unwrap();
1898        let genewise =
1899            splice_variants(&counts, 13, 6, &gene_id, &group, &[0.1, 0.1, 0.1, 0.5, 0.1]).unwrap();
1900
1901        assert_relative_eq!(
1902            genewise.gene_f_statistic[0],
1903            common.gene_f_statistic[0],
1904            max_relative = 1e-12
1905        );
1906        assert!(genewise.gene_f_statistic[3] < common.gene_f_statistic[3]);
1907    }
1908
1909    /// Simes on one p-value is that p-value, and the result never exceeds one.
1910    #[test]
1911    fn test_simes_bounds() {
1912        assert_relative_eq!(simes(&[0.4]), 0.4, max_relative = 1e-12);
1913        assert_relative_eq!(simes(&[0.5, 0.73, 0.89]), 0.89, max_relative = 1e-12);
1914        assert_relative_eq!(simes(&[0.01, 0.5, 0.9]), 0.03, max_relative = 1e-12);
1915        assert_eq!(simes(&[]), 1.0);
1916    }
1917
1918    /// A design with one column has no comparison to make.
1919    #[test]
1920    fn test_rejects_a_single_column_design() {
1921        let (counts, gene_id, _) = gate();
1922        let design = vec![1.0; 6];
1923        let offset = offsets_from(&counts, 6, 6);
1924        let fit = glm_fit(
1925            &counts,
1926            6,
1927            6,
1928            &design,
1929            1,
1930            &Recycled::scalar(0.1),
1931            &offset,
1932            None,
1933            0.0,
1934        )
1935        .unwrap();
1936        let input = GlmTestInput {
1937            counts: &counts,
1938            n_genes: 6,
1939            n_samples: 6,
1940            design: &design,
1941            n_coef: 1,
1942            dispersion: &Recycled::scalar(0.1),
1943            offset: &offset,
1944            weights: None,
1945            log_cpm: None,
1946        };
1947        let err =
1948            diff_splice(&input, &fit, &gene_id, &Tested::Coef(vec![0]), None, None).unwrap_err();
1949        assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
1950    }
1951
1952    #[test]
1953    fn test_rejects_a_gene_id_of_the_wrong_length() {
1954        let (counts, _, design) = gate();
1955        let (fit, offset) = exon_fit(&counts, 6, &design);
1956        let input = GlmTestInput {
1957            counts: &counts,
1958            n_genes: 6,
1959            n_samples: 6,
1960            design: &design,
1961            n_coef: 2,
1962            dispersion: &Recycled::scalar(0.1),
1963            offset: &offset,
1964            weights: None,
1965            log_cpm: None,
1966        };
1967        let err =
1968            diff_splice(&input, &fit, &[1, 1, 2], &Tested::Coef(vec![1]), None, None).unwrap_err();
1969        assert!(matches!(
1970            err,
1971            EdgeErrors::LengthMismatch {
1972                name: "gene_id",
1973                expected: 6,
1974                got: 3
1975            }
1976        ));
1977    }
1978
1979    #[test]
1980    fn test_rejects_a_coefficient_out_of_range() {
1981        let (counts, gene_id, design) = gate();
1982        let (fit, offset) = exon_fit(&counts, 6, &design);
1983        let input = GlmTestInput {
1984            counts: &counts,
1985            n_genes: 6,
1986            n_samples: 6,
1987            design: &design,
1988            n_coef: 2,
1989            dispersion: &Recycled::scalar(0.1),
1990            offset: &offset,
1991            weights: None,
1992            log_cpm: None,
1993        };
1994        let err =
1995            diff_splice(&input, &fit, &gene_id, &Tested::Coef(vec![7]), None, None).unwrap_err();
1996        assert!(matches!(
1997            err,
1998            EdgeErrors::CoefOutOfRange {
1999                index: 7,
2000                n_coef: 2
2001            }
2002        ));
2003    }
2004
2005    #[test]
2006    fn test_rejects_an_empty_coefficient_list() {
2007        let (counts, gene_id, design) = gate();
2008        let (fit, offset) = exon_fit(&counts, 6, &design);
2009        let input = GlmTestInput {
2010            counts: &counts,
2011            n_genes: 6,
2012            n_samples: 6,
2013            design: &design,
2014            n_coef: 2,
2015            dispersion: &Recycled::scalar(0.1),
2016            offset: &offset,
2017            weights: None,
2018            log_cpm: None,
2019        };
2020        let err = diff_splice(
2021            &input,
2022            &fit,
2023            &gene_id,
2024            &Tested::Coef(Vec::new()),
2025            None,
2026            None,
2027        )
2028        .unwrap_err();
2029        assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
2030    }
2031
2032    #[test]
2033    fn test_rejects_a_contrast_of_the_wrong_length() {
2034        let (counts, gene_id, design) = gate();
2035        let (fit, offset) = exon_fit(&counts, 6, &design);
2036        let input = GlmTestInput {
2037            counts: &counts,
2038            n_genes: 6,
2039            n_samples: 6,
2040            design: &design,
2041            n_coef: 2,
2042            dispersion: &Recycled::scalar(0.1),
2043            offset: &offset,
2044            weights: None,
2045            log_cpm: None,
2046        };
2047        let err = diff_splice(
2048            &input,
2049            &fit,
2050            &gene_id,
2051            &Tested::Contrast {
2052                values: vec![1.0, 0.0, 0.0],
2053                n_contrasts: 1,
2054            },
2055            None,
2056            None,
2057        )
2058        .unwrap_err();
2059        assert!(matches!(
2060            err,
2061            EdgeErrors::LengthMismatch {
2062                name: "contrast",
2063                ..
2064            }
2065        ));
2066    }
2067
2068    /// Every gene having a single exon leaves nothing testable.
2069    #[test]
2070    fn test_rejects_when_no_gene_has_more_than_one_exon() {
2071        let (counts, _, design) = gate();
2072        let (fit, offset) = exon_fit(&counts, 6, &design);
2073        let input = GlmTestInput {
2074            counts: &counts,
2075            n_genes: 6,
2076            n_samples: 6,
2077            design: &design,
2078            n_coef: 2,
2079            dispersion: &Recycled::scalar(0.1),
2080            offset: &offset,
2081            weights: None,
2082            log_cpm: None,
2083        };
2084        let err = diff_splice(
2085            &input,
2086            &fit,
2087            &[1, 2, 3, 4, 5, 6],
2088            &Tested::Coef(vec![1]),
2089            None,
2090            None,
2091        )
2092        .unwrap_err();
2093        assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
2094    }
2095
2096    #[test]
2097    fn test_rejects_a_negative_prior_count() {
2098        let (counts, gene_id, design) = gate();
2099        let (fit, offset) = exon_fit(&counts, 6, &design);
2100        let input = GlmTestInput {
2101            counts: &counts,
2102            n_genes: 6,
2103            n_samples: 6,
2104            design: &design,
2105            n_coef: 2,
2106            dispersion: &Recycled::scalar(0.1),
2107            offset: &offset,
2108            weights: None,
2109            log_cpm: None,
2110        };
2111        let err = diff_splice(
2112            &input,
2113            &fit,
2114            &gene_id,
2115            &Tested::Coef(vec![1]),
2116            None,
2117            Some(DiffSpliceParams::new(-1.0, None)),
2118        )
2119        .unwrap_err();
2120        assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
2121    }
2122
2123    #[test]
2124    fn test_rejects_a_quasi_likelihood_summary_of_the_wrong_length() {
2125        let (counts, gene_id, design) = gate();
2126        let (fit, offset) = exon_fit(&counts, 6, &design);
2127        let input = GlmTestInput {
2128            counts: &counts,
2129            n_genes: 6,
2130            n_samples: 6,
2131            design: &design,
2132            n_coef: 2,
2133            dispersion: &Recycled::scalar(0.1),
2134            offset: &offset,
2135            weights: None,
2136            log_cpm: None,
2137        };
2138        let ql = DiffSpliceQl {
2139            df_prior: &[5.0],
2140            df_residual: &[4.0, 4.0],
2141            deviance: &[1.0; 6],
2142            legacy_zeros: false,
2143            average_ql_dispersion: None,
2144        };
2145        let err = diff_splice(
2146            &input,
2147            &fit,
2148            &gene_id,
2149            &Tested::Coef(vec![1]),
2150            Some(&ql),
2151            None,
2152        )
2153        .unwrap_err();
2154        assert!(matches!(
2155            err,
2156            EdgeErrors::LengthMismatch {
2157                name: "ql.df_residual",
2158                ..
2159            }
2160        ));
2161    }
2162
2163    #[test]
2164    fn test_rejects_a_fit_of_the_wrong_length() {
2165        let (counts, gene_id, design) = gate();
2166        let (mut fit, offset) = exon_fit(&counts, 6, &design);
2167        fit.deviance.pop();
2168        let input = GlmTestInput {
2169            counts: &counts,
2170            n_genes: 6,
2171            n_samples: 6,
2172            design: &design,
2173            n_coef: 2,
2174            dispersion: &Recycled::scalar(0.1),
2175            offset: &offset,
2176            weights: None,
2177            log_cpm: None,
2178        };
2179        let err =
2180            diff_splice(&input, &fit, &gene_id, &Tested::Coef(vec![1]), None, None).unwrap_err();
2181        assert!(matches!(
2182            err,
2183            EdgeErrors::LengthMismatch {
2184                name: "fit.deviance",
2185                ..
2186            }
2187        ));
2188    }
2189
2190    #[test]
2191    fn test_splice_variants_rejects_a_single_group() {
2192        let (counts, gene_id, _) = mixed();
2193        let err = splice_variants(&counts, 13, 6, &gene_id, &[1; 6], &[0.1]).unwrap_err();
2194        assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
2195    }
2196
2197    #[test]
2198    fn test_splice_variants_rejects_a_bad_dispersion_length() {
2199        let (counts, gene_id, _) = mixed();
2200        let group = vec![1, 1, 1, 2, 2, 2];
2201        let err = splice_variants(&counts, 13, 6, &gene_id, &group, &[0.1, 0.2]).unwrap_err();
2202        assert!(matches!(
2203            err,
2204            EdgeErrors::LengthMismatch {
2205                name: "dispersion",
2206                ..
2207            }
2208        ));
2209    }
2210
2211    #[test]
2212    fn test_splice_variants_rejects_a_negative_dispersion() {
2213        let (counts, gene_id, _) = mixed();
2214        let group = vec![1, 1, 1, 2, 2, 2];
2215        let err = splice_variants(&counts, 13, 6, &gene_id, &group, &[-0.1]).unwrap_err();
2216        assert!(matches!(err, EdgeErrors::InvalidDispersion(_)));
2217    }
2218
2219    #[test]
2220    fn test_splice_variants_rejects_an_all_zero_matrix() {
2221        let err = splice_variants(&[0.0_f64; 12], 2, 6, &[1, 1], &[1, 1, 1, 2, 2, 2], &[0.1])
2222            .unwrap_err();
2223        assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
2224    }
2225
2226    #[test]
2227    fn test_splice_variants_rejects_a_group_of_the_wrong_length() {
2228        let (counts, gene_id, _) = mixed();
2229        let err = splice_variants(&counts, 13, 6, &gene_id, &[1, 2], &[0.1]).unwrap_err();
2230        assert!(matches!(
2231            err,
2232            EdgeErrors::LengthMismatch { name: "group", .. }
2233        ));
2234    }
2235
2236    #[test]
2237    fn test_splice_variants_rejects_empty_counts() {
2238        let err =
2239            splice_variants(&[0.0_f64; 0], 0, 6, &[], &[1, 1, 1, 2, 2, 2], &[0.1]).unwrap_err();
2240        assert!(matches!(err, EdgeErrors::EmptyCounts { .. }));
2241    }
2242
2243    /// `f32` counts run the same algorithm; only the count conversion differs.
2244    #[test]
2245    fn test_runs_on_f32_counts() {
2246        let (counts, gene_id, design) = gate();
2247        let counts32: Vec<f32> = counts.iter().map(|v| *v as f32).collect();
2248        let offset = offsets_from(&counts, 6, 6);
2249        let fit = glm_fit(
2250            &counts32,
2251            6,
2252            6,
2253            &design,
2254            2,
2255            &Recycled::scalar(0.1),
2256            &offset,
2257            None,
2258            DEFAULT_PRIOR_COUNT,
2259        )
2260        .unwrap();
2261        let input = GlmTestInput {
2262            counts: &counts32,
2263            n_genes: 6,
2264            n_samples: 6,
2265            design: &design,
2266            n_coef: 2,
2267            dispersion: &Recycled::scalar(0.1),
2268            offset: &offset,
2269            weights: None,
2270            log_cpm: None,
2271        };
2272        let out = diff_splice(&input, &fit, &gene_id, &Tested::Coef(vec![1]), None, None).unwrap();
2273        assert_relative_eq!(
2274            out.gene_simes_p[0],
2275            0.011_032_481_883_944_744,
2276            max_relative = 1e-6
2277        );
2278    }
2279}