Skip to main content

edge_rs/exact/
mod.rs

1//! The classic two-group exact test, edgeR's `exactTest`.
2//!
3//! Three parts:
4//!
5//! * [`q2q_nbinom`] maps a count from one NB mean to another through matched
6//!   quantiles, averaging a normal and a gamma approximation.
7//! * [`equalize_lib_sizes`] runs that mapping over a matrix to produce
8//!   pseudo-counts on one common library size.
9//! * [`exact_test`] fits the fold change, builds the pseudo-counts and sums the
10//!   conditional NB mass over one of three rejection regions.
11//!
12//! Every kernel is a rayon fan-out over genes with a per-thread scratch buffer
13//! for the convolution.
14//!
15//! ### Departures
16//!
17//! * edgePython stubs the deviance and small-p regions out to the double-tail
18//!   test; both are implemented here against edgeR. See
19//!   `UPSTREAM_DEVIATIONS.md` A15.
20//! * `q2qnbinom` stays in log space like edgeR's `log.p = TRUE`. The normal
21//!   round trip is replaced by the linear map it equals, and the upper gamma
22//!   tail uses a log-space incomplete gamma defined here, because
23//!   [`crate::numeric::dist`] only has natural-scale `gamma_cdf` and
24//!   `gamma_ppf`. See `UPSTREAM_DEVIATIONS.md` A16.
25//! * `exactTestBySmallP` and `binomTest` have bugs in edgeR that are not
26//!   reproduced. See `UPSTREAM_DEVIATIONS.md` B3 and B4.
27
28use std::f64::consts::LN_2;
29
30use rayon::prelude::*;
31
32use crate::core::dgelist::DgeList;
33use crate::core::expression::{ave_log_cpm, column_sums};
34use crate::glm::deviance::unit_nb_deviance;
35use crate::glm::one_group::mglm_one_group;
36use crate::numeric::dist::nbinom_ln_pmf;
37use crate::numeric::dist::{beta_cdf, beta_ppf, beta_sf, chisq_sf, gamma_cdf, gamma_ppf};
38use crate::numeric::gamma::ln_gamma;
39use crate::prelude::*;
40
41////////////
42// Consts //
43////////////
44
45/// Row sum above which, in both groups, the beta approximation replaces the
46/// convolution.
47///
48/// edgeR's `big.count` default. The convolution is `O(s)` per gene, so past this
49/// the beta approximation takes over.
50pub const BIG_COUNT_DEFAULT: f64 = 900.0;
51
52/// Prior count added before the fold change is fitted.
53///
54/// edgeR's `exactTest` default. Small on purpose: it only damps the fold change
55/// of a gene that is zero in one group.
56pub const PRIOR_COUNT_DEFAULT: f64 = 0.125;
57
58/// Prior count [`exact_test`] passes to `aveLogCPM` for the reported log-CPM.
59///
60/// edgeR's `aveLogCPM` default. Not [`PRIOR_COUNT_DEFAULT`]: this only sets the
61/// floor of the abundance covariate.
62const AVE_LOG_CPM_PRIOR: f64 = 2.0;
63
64/// Below this, a mean is treated as zero by [`q2q_nbinom`] and nudged.
65///
66/// edgeR's `eps`. Both means are nudged together when either trips it, which
67/// keeps the mapping the identity for a gene fitted at zero.
68const Q2Q_EPS: f64 = 1e-14;
69
70/// Nudge added to a mean that fell below [`Q2Q_EPS`].
71///
72/// edgeR's 0.25: small enough not to move a real pseudo-count, large enough to
73/// keep the gamma shape away from zero.
74const Q2Q_ZERO_NUDGE: f64 = 0.25;
75
76/// Relative convergence tolerance for the upper gamma quantile, in `ln(x)`.
77///
78/// Roughly 4 ulp, as [`crate::numeric::dist::gamma_ppf`] uses on the lower tail.
79/// Tighter only cycles on the last bit of the continued fraction.
80const GAMMA_ISF_REL_TOL: f64 = 1e-15;
81
82/// Iteration budget for the upper gamma quantile and its bracket search.
83///
84/// A bracket 128 wide in `ln(x)` covers everything an `f64` can represent, so
85/// this is a runaway guard, not a working limit.
86const GAMMA_ISF_MAX_ITER: usize = 256;
87
88/// Floor for the modified Lentz continued fraction, guarding a zero pivot.
89///
90/// ### References
91///
92/// Press et al., Numerical Recipes, 3rd ed., section 6.2
93const LENTZ_TINY: f64 = 1e-300;
94
95/// Relative convergence tolerance for the continued fraction.
96const LENTZ_EPS: f64 = 2.220446049250313e-16;
97
98/// Slack allowed when deciding whether an outcome is as improbable as the
99/// observed one, in [`binom_test_one`].
100///
101/// R's `binom.test` carries the same `1 + 1e-7`. For `p = 1/3` and a total of
102/// `3k + 2`, outcomes `k` and `k + 1` are *exactly* equiprobable, and the last
103/// bit of the mass routine would otherwise decide which side each lands on.
104const BINOM_TIE_TOL: f64 = 1.0 + 1e-7;
105
106/////////////////////
107// RejectionRegion //
108/////////////////////
109
110/// Which set of outcomes counts as "at least as extreme as observed".
111///
112/// All three condition on the total count and sum NB mass over a region. With
113/// equal group sizes the regions coincide and, as in edgeR,
114/// [`RejectionRegion::Deviance`] and [`RejectionRegion::SmallP`] go straight to
115/// the double-tail test.
116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
117pub enum RejectionRegion {
118    /// Double the smaller tail. edgeR's default, and the only region with the
119    /// large-count beta approximation.
120    DoubleTail,
121    /// Every split whose negative binomial deviance is at least the observed
122    /// one.
123    Deviance,
124    /// Every split no more probable than the observed one.
125    SmallP,
126}
127
128/////////////////////
129// ExactTestResult //
130/////////////////////
131
132/// The three columns edgeR's `exactTest` reports.
133#[derive(Clone, Debug)]
134pub struct ExactTestResult {
135    /// Log2 fold change of the second group over the first, one per gene.
136    pub log_fc: Vec<f64>,
137    /// Average log2 counts per million, one per gene.
138    pub log_cpm: Vec<f64>,
139    /// Raw exact-test p-value, one per gene.
140    pub p_value: Vec<f64>,
141}
142
143/////////////////////
144// ExactTestParams //
145/////////////////////
146
147/// Tuning knobs for [`exact_test`].
148#[derive(Clone, Copy, Debug)]
149pub struct ExactTestParams {
150    /// Which rejection region to sum over.
151    pub rejection_region: RejectionRegion,
152    /// Row sum above which, in both groups, the beta approximation is used.
153    /// Only consulted by [`RejectionRegion::DoubleTail`].
154    pub big_count: f64,
155    /// Prior count added before the fold change is fitted.
156    pub prior_count: f64,
157}
158
159impl Default for ExactTestParams {
160    /// edgeR's defaults: double tail, `big.count = 900`, `prior.count = 0.125`.
161    fn default() -> Self {
162        Self {
163            rejection_region: RejectionRegion::DoubleTail,
164            big_count: BIG_COUNT_DEFAULT,
165            prior_count: PRIOR_COUNT_DEFAULT,
166        }
167    }
168}
169
170impl ExactTestParams {
171    /// Builds a parameter set explicitly.
172    ///
173    /// ### Params
174    ///
175    /// * `rejection_region` - Which outcomes count as extreme
176    /// * `big_count` - Beta approximation threshold, non-negative
177    /// * `prior_count` - Prior count for the fold change, non-negative
178    ///
179    /// ### Returns
180    ///
181    /// The parameter set. Values are validated at the point of use in
182    /// [`exact_test`], not here.
183    pub fn new(rejection_region: RejectionRegion, big_count: f64, prior_count: f64) -> Self {
184        Self {
185            rejection_region,
186            big_count,
187            prior_count,
188        }
189    }
190}
191
192//////////////////////////////////////
193// Log-space upper incomplete gamma //
194//////////////////////////////////////
195
196/// Modified Lentz evaluation of the continued fraction behind `Q(a, x)`.
197///
198/// Returns the fraction without the `exp(a ln x - x - ln Gamma(a))` prefactor,
199/// so the caller can keep that in logs. Only valid for `x >= a + 1`, where it
200/// converges quickly.
201///
202/// ### Params
203///
204/// * `a` - Shape, strictly positive
205/// * `x` - Argument, at least `a + 1`
206///
207/// ### Returns
208///
209/// The continued fraction value.
210///
211/// ### References
212///
213/// Press et al., Numerical Recipes, 3rd ed., section 6.2
214fn gamma_cont_frac(a: f64, x: f64) -> f64 {
215    let mut b = x + 1.0 - a;
216    let mut c = 1.0 / LENTZ_TINY;
217    let mut d = 1.0 / b;
218    let mut h = d;
219    for i in 1..=GAMMA_ISF_MAX_ITER * 4 {
220        let i = i as f64;
221        let an = -i * (i - a);
222        b += 2.0;
223        d = an * d + b;
224        if d.abs() < LENTZ_TINY {
225            d = LENTZ_TINY;
226        }
227        c = b + an / c;
228        if c.abs() < LENTZ_TINY {
229            c = LENTZ_TINY;
230        }
231        d = 1.0 / d;
232        let delta = d * c;
233        h *= delta;
234        if (delta - 1.0).abs() <= LENTZ_EPS {
235            break;
236        }
237    }
238    h
239}
240
241/// Log of the regularised upper incomplete gamma, `ln Q(a, x)`.
242///
243/// Exists instead of `gamma_sf(...).ln()` because `q2qnbinom` can map counts
244/// hundreds of standard deviations above the fitted mean: 6000 against a mean
245/// of 1000 at dispersion 0.001 has `Q = e^-1605`, a flat zero on the natural
246/// scale.
247///
248/// Below `x = a + 1` `chisq_sf` is computed directly and logged. Above it the
249/// prefactor stays in logs and only the order-one continued fraction is
250/// exponentiated.
251///
252/// ### Params
253///
254/// * `a` - Shape, strictly positive
255/// * `x` - Argument, non-negative
256///
257/// ### Returns
258///
259/// `ln Q(a, x)` in `(-inf, 0]`, or [`EdgeErrors::InvalidArgument`] for a
260/// non-positive shape.
261fn ln_reg_gamma_upper(a: f64, x: f64) -> Result<f64, EdgeErrors> {
262    if x <= 0.0 {
263        return Ok(0.0);
264    }
265    if x < a + 1.0 {
266        // Q is at least about 0.15 here, so there is nothing to lose to the log.
267        return Ok(chisq_sf(2.0 * x, 2.0 * a)?.ln());
268    }
269    Ok(a * x.ln() - x - ln_gamma(a) + gamma_cont_frac(a, x).ln())
270}
271
272/// Starting point for [`ln_reg_gamma_upper_inv`], in `x` at unit scale.
273///
274/// For `x` well past the mean, `ln Q(a, x) ~ (a - 1) ln x - x - ln Gamma(a)`,
275/// i.e. `x = c + (a - 1) ln x` with `c = -ln q - ln Gamma(a)`, which converges
276/// in a few fixed-point steps when `c` exceeds the shape. Otherwise the shape
277/// itself is the start.
278///
279/// The caller brackets and bisects, so a poor guess costs iterations, not
280/// correctness.
281///
282/// ### Params
283///
284/// * `ln_q` - Target log upper-tail probability, at most 0
285/// * `a` - Shape, strictly positive
286///
287/// ### Returns
288///
289/// An initial `x`, strictly positive and finite.
290fn gamma_isf_start(ln_q: f64, a: f64) -> f64 {
291    let c = -ln_q - ln_gamma(a);
292    if !c.is_finite() || c <= a {
293        return a.max(f64::MIN_POSITIVE);
294    }
295    let mut x = c.max(a + 1.0);
296    for _ in 0..8 {
297        let next = c + (a - 1.0) * x.ln();
298        if !(next.is_finite() && next > 0.0) {
299            break;
300        }
301        x = next;
302    }
303    x
304}
305
306/// Inverts [`ln_reg_gamma_upper`] at unit scale.
307///
308/// Newton in `t = ln x` on a bracketed, decreasing residual, falling back to
309/// bisection when a step leaves the bracket. The derivative
310/// `d ln Q / dt = -x f(x) / Q` is formed in logs.
311///
312/// ### Params
313///
314/// * `ln_q` - Target log upper-tail probability, at most 0
315/// * `a` - Shape, strictly positive
316///
317/// ### Returns
318///
319/// `x` with `ln Q(a, x) = ln_q`, or [`EdgeErrors::NoConvergence`] if the bracket
320/// has not closed within [`GAMMA_ISF_MAX_ITER`].
321fn ln_reg_gamma_upper_inv(ln_q: f64, a: f64) -> Result<f64, EdgeErrors> {
322    if ln_q >= 0.0 {
323        return Ok(0.0);
324    }
325    if ln_q == f64::NEG_INFINITY {
326        return Ok(f64::INFINITY);
327    }
328    let ln_norm = ln_gamma(a);
329
330    // Decreasing in t: Q falls as x grows, so the residual does too.
331    let residual =
332        |t: f64| -> Result<f64, EdgeErrors> { Ok(ln_reg_gamma_upper(a, t.exp())? - ln_q) };
333
334    let mut t = gamma_isf_start(ln_q, a).ln();
335    let mut lo = t;
336    let mut hi = t;
337    if residual(t)? > 0.0 {
338        // Q is still too large: walk the upper end out until it undershoots.
339        for _ in 0..GAMMA_ISF_MAX_ITER {
340            hi += 1.0;
341            if residual(hi)? <= 0.0 {
342                break;
343            }
344            lo = hi;
345        }
346    } else {
347        for _ in 0..GAMMA_ISF_MAX_ITER {
348            lo -= 1.0;
349            if residual(lo)? > 0.0 {
350                break;
351            }
352            hi = lo;
353        }
354    }
355
356    for _ in 0..GAMMA_ISF_MAX_ITER {
357        if !(t > lo && t < hi) {
358            t = 0.5 * (lo + hi);
359        }
360        let f = residual(t)?;
361        // Residual falls with t, so a positive value sits below the root.
362        if f > 0.0 {
363            lo = t;
364        } else {
365            hi = t;
366        }
367        let tol = GAMMA_ISF_REL_TOL * (1.0 + t.abs());
368        if hi - lo <= tol {
369            return Ok(t.exp());
370        }
371        // -d(residual)/dt = exp(a t - e^t - ln Gamma(a) - ln Q), kept in logs.
372        let slope = (a * t - t.exp() - ln_norm - (f + ln_q)).exp();
373        let t_next = if slope > 0.0 && slope.is_finite() {
374            t + f / slope
375        } else {
376            0.5 * (lo + hi)
377        };
378        let step = (t_next - t).abs();
379        t = t_next;
380        if step <= tol {
381            return Ok(t.clamp(lo, hi).exp());
382        }
383    }
384
385    Err(EdgeErrors::NoConvergence {
386        routine: "ln_reg_gamma_upper_inv",
387        iterations: GAMMA_ISF_MAX_ITER,
388        last_delta: hi - lo,
389    })
390}
391
392////////////////
393// Validation //
394////////////////
395
396/// Rejects a dispersion outside `[0, inf)`.
397///
398/// ### Params
399///
400/// * `dispersion` - Value supplied by the caller
401///
402/// ### Returns
403///
404/// `Ok(())`, or [`EdgeErrors::InvalidDispersion`].
405fn check_dispersion(dispersion: f64) -> Result<(), EdgeErrors> {
406    if !dispersion.is_finite() || dispersion < 0.0 {
407        return Err(EdgeErrors::InvalidDispersion(dispersion));
408    }
409    Ok(())
410}
411
412/// Rejects a slice holding a negative or non-finite value.
413///
414/// ### Params
415///
416/// * `name` - Argument name, used verbatim in the error message
417/// * `values` - Values to check
418///
419/// ### Returns
420///
421/// `Ok(())`, or [`EdgeErrors::InvalidArgument`] naming the offending value.
422fn check_non_negative(name: &str, values: &[f64]) -> Result<(), EdgeErrors> {
423    if let Some(bad) = values.iter().find(|v| !v.is_finite() || **v < 0.0) {
424        return Err(EdgeErrors::InvalidArgument(format!(
425            "'{name}' must be non-negative and finite, found {bad}"
426        )));
427    }
428    Ok(())
429}
430
431///////////////
432// q2qnbinom //
433///////////////
434
435/// Maps one count from one negative binomial mean to another.
436///
437/// The scalar kernel behind [`q2q_nbinom`], which documents the algorithm.
438///
439/// ### Params
440///
441/// * `x` - Count to map, non-negative
442/// * `input_mean` - Mean the count was drawn under, non-negative
443/// * `output_mean` - Mean to map it onto, non-negative
444/// * `dispersion` - Dispersion, non-negative and finite
445///
446/// ### Returns
447///
448/// The mapped count, or [`EdgeErrors`] propagated from the quantile solvers.
449#[inline]
450fn q2q_one(x: f64, input_mean: f64, output_mean: f64, dispersion: f64) -> Result<f64, EdgeErrors> {
451    // Either mean falling below eps nudges both, so a gene fitted at zero maps
452    // to itself rather than dividing by zero.
453    let (mi, mo) = if input_mean < Q2Q_EPS || output_mean < Q2Q_EPS {
454        (input_mean + Q2Q_ZERO_NUDGE, output_mean + Q2Q_ZERO_NUDGE)
455    } else {
456        (input_mean, output_mean)
457    };
458
459    let ri = 1.0 + dispersion * mi;
460    let vi = mi * ri;
461    let ro = 1.0 + dispersion * mo;
462    let vo = mo * ro;
463    let q1 = mo + (vo / vi).sqrt() * (x - mi);
464
465    let shape_i = mi / ri;
466    let shape_o = mo / ro;
467    let q2 = if x >= mi {
468        let ln_q = ln_reg_gamma_upper(shape_i, x / ri)?;
469        ro * ln_reg_gamma_upper_inv(ln_q, shape_o)?
470    } else {
471        let p = gamma_cdf(x, shape_i, ri)?;
472        gamma_ppf(p, shape_o, ro)?
473    };
474
475    Ok(0.5 * (q1 + q2))
476}
477
478/// Quantile-to-quantile mapping between two negative binomial means.
479///
480/// edgeR's `q2qnbinom`. Each count is placed at its quantile under
481/// `NB(input_mean, dispersion)` and read back off `NB(output_mean, dispersion)`.
482/// The NB quantile has no closed form, so edgeR averages a normal and a gamma
483/// approximation with matching mean and variance.
484///
485/// The tail is chosen per element by `x >= input_mean`, so the small probability
486/// is never formed as one minus a large one.
487///
488/// ### Notes
489///
490/// **Two departures from edgeR's arithmetic** (see `UPSTREAM_DEVIATIONS.md`
491/// A16):
492///
493/// * The normal half is the linear map `output_mean + sd_out (x - input_mean) /
494///   sd_in`, which `qnorm(pnorm(...))` reduces to. Agrees to the last bit or two.
495/// * The upper gamma tail uses `ln_reg_gamma_upper` and its inverse, not
496///   `gamma_cdf` and `gamma_ppf`.
497///
498/// ### Params
499///
500/// * `x` - Counts to map, non-negative
501/// * `input_mean` - Mean each count was drawn under, same length as `x`,
502///   non-negative
503/// * `output_mean` - Mean to map each count onto, same length as `x`,
504///   non-negative
505/// * `dispersion` - Dispersion shared by every element, non-negative and
506///   finite. Per-gene dispersions mean one call per gene, as in
507///   [`equalize_lib_sizes`].
508///
509/// ### Returns
510///
511/// The mapped counts, same length as `x`. Errors are
512/// [`EdgeErrors::LengthMismatch`] if the three slices disagree,
513/// [`EdgeErrors::InvalidDispersion`] for a negative or non-finite dispersion,
514/// and [`EdgeErrors::InvalidArgument`] for a negative count or mean.
515///
516/// ### References
517///
518/// Robinson and Smyth, Biostatistics 9(2), 2008
519pub fn q2q_nbinom(
520    x: &[f64],
521    input_mean: &[f64],
522    output_mean: &[f64],
523    dispersion: f64,
524) -> Result<Vec<f64>, EdgeErrors> {
525    if input_mean.len() != x.len() {
526        return Err(EdgeErrors::LengthMismatch {
527            name: "input_mean",
528            expected: x.len(),
529            got: input_mean.len(),
530        });
531    }
532    if output_mean.len() != x.len() {
533        return Err(EdgeErrors::LengthMismatch {
534            name: "output_mean",
535            expected: x.len(),
536            got: output_mean.len(),
537        });
538    }
539    check_dispersion(dispersion)?;
540    check_non_negative("x", x)?;
541    check_non_negative("input_mean", input_mean)?;
542    check_non_negative("output_mean", output_mean)?;
543
544    x.iter()
545        .zip(input_mean.iter())
546        .zip(output_mean.iter())
547        .map(|((v, im), om)| q2q_one(*v, *im, *om, dispersion))
548        .collect()
549}
550
551///////////////////
552// Pseudo-counts //
553///////////////////
554
555/// Distinct labels in first-appearance order, with a mask per label.
556///
557/// First-appearance order, as R's `unique` on a factor.
558///
559/// ### Params
560///
561/// * `group` - One label per sample
562///
563/// ### Returns
564///
565/// One entry per distinct label, holding the sample indices carrying it.
566fn group_columns(group: &[usize]) -> Vec<Vec<usize>> {
567    let mut labels: Vec<usize> = Vec::new();
568    let mut columns: Vec<Vec<usize>> = Vec::new();
569    for (sample, label) in group.iter().enumerate() {
570        match labels.iter().position(|l| l == label) {
571            Some(k) => columns[k].push(sample),
572            None => {
573                labels.push(*label);
574                columns.push(vec![sample]);
575            }
576        }
577    }
578    columns
579}
580
581/// Copies a subset of columns out of a row-major matrix, as `f64`.
582///
583/// ### Params
584///
585/// * `counts` - Row-major counts, `n_genes * n_samples`
586/// * `n_genes` - Number of genes
587/// * `n_samples` - Number of samples in `counts`
588/// * `columns` - Sample indices to keep, in the order wanted
589///
590/// ### Returns
591///
592/// A row-major `n_genes * columns.len()` matrix.
593fn select_columns<T: EdgeFloat>(
594    counts: &[T],
595    n_genes: usize,
596    n_samples: usize,
597    columns: &[usize],
598) -> Vec<f64> {
599    let mut out = vec![0.0_f64; n_genes * columns.len()];
600    out.par_chunks_mut(columns.len())
601        .enumerate()
602        .for_each(|(gene, row)| {
603            let src = &counts[gene * n_samples..(gene + 1) * n_samples];
604            for (dst, &col) in row.iter_mut().zip(columns.iter()) {
605                *dst = src[col].to_f64().unwrap_or(f64::NAN);
606            }
607        });
608    out
609}
610
611/// Expands a recycled dispersion into one value per gene.
612///
613/// Only the gene axis is meaningful here, so [`Recycled::BySample`] and
614/// [`Recycled::Full`] are errors.
615///
616/// ### Params
617///
618/// * `dispersion` - Dispersion, recycled over genes and samples
619/// * `n_genes` - Number of genes
620///
621/// ### Returns
622///
623/// One dispersion per gene, or [`EdgeErrors::LengthMismatch`] on a bad length,
624/// [`EdgeErrors::InvalidDispersion`] on a negative or non-finite value and
625/// [`EdgeErrors::InvalidArgument`] on a sample-varying form.
626fn per_gene_dispersion(dispersion: &Recycled<f64>, n_genes: usize) -> Result<Vec<f64>, EdgeErrors> {
627    let out = match dispersion {
628        Recycled::Scalar(v) => vec![*v; n_genes],
629        Recycled::ByGene(v) => {
630            if v.len() != n_genes {
631                return Err(EdgeErrors::LengthMismatch {
632                    name: "by_gene",
633                    expected: n_genes,
634                    got: v.len(),
635                });
636            }
637            v.clone()
638        }
639        Recycled::BySample(_) | Recycled::Full(_) => {
640            return Err(EdgeErrors::InvalidArgument(
641                "the exact test needs a dispersion that varies by gene only; got a \
642                 sample-varying form"
643                    .to_string(),
644            ));
645        }
646    };
647    for d in &out {
648        check_dispersion(*d)?;
649    }
650    Ok(out)
651}
652
653/// Pseudo-counts on a common library size, edgeR's `equalizeLibSizes`.
654///
655/// Conditioning on the total count needs equal library sizes, so within each
656/// group this fits an intercept-only NB GLM per gene, forms the fitted mean at
657/// the observed and at the common library size (their geometric mean), and maps
658/// between the two with [`q2q_nbinom`].
659///
660/// Negative pseudo-counts are clamped to zero, as in edgeR; the normal half of
661/// the mapping is unbounded below.
662///
663/// ### Params
664///
665/// * `counts` - Row-major counts, `n_genes * n_samples`
666/// * `n_genes` - Number of genes
667/// * `n_samples` - Number of samples
668/// * `group` - One label per sample, or `None` for a single group
669/// * `dispersion` - Dispersion, recycled over genes. [`Recycled::BySample`] and
670///   [`Recycled::Full`] are rejected.
671/// * `lib_size` - Library size per sample, or `None` for the column sums
672///
673/// ### Returns
674///
675/// Pseudo-counts, row-major `n_genes * n_samples`, on the common library size
676/// `exp(mean(ln(lib_size)))` (not returned). Errors are
677/// [`EdgeErrors::EmptyCounts`] for a zero
678/// dimension, [`EdgeErrors::LengthMismatch`] for a `group` or `lib_size` of the
679/// wrong length, [`EdgeErrors::InvalidDispersion`] for a negative dispersion
680/// and [`EdgeErrors::InvalidArgument`] for a non-positive library size.
681pub fn equalize_lib_sizes<T: EdgeFloat>(
682    counts: &[T],
683    n_genes: usize,
684    n_samples: usize,
685    group: Option<&[usize]>,
686    dispersion: &Recycled<f64>,
687    lib_size: Option<&[f64]>,
688) -> Result<Vec<f64>, EdgeErrors> {
689    if n_genes == 0 || n_samples == 0 {
690        return Err(EdgeErrors::EmptyCounts { n_genes, n_samples });
691    }
692    if counts.len() != n_genes * n_samples {
693        return Err(EdgeErrors::LengthMismatch {
694            name: "counts",
695            expected: n_genes * n_samples,
696            got: counts.len(),
697        });
698    }
699    let per_gene = per_gene_dispersion(dispersion, n_genes)?;
700
701    let ones = vec![0_usize; n_samples];
702    let group = match group {
703        Some(g) => {
704            if g.len() != n_samples {
705                return Err(EdgeErrors::LengthMismatch {
706                    name: "group",
707                    expected: n_samples,
708                    got: g.len(),
709                });
710            }
711            g
712        }
713        None => &ones,
714    };
715
716    let derived: Vec<f64>;
717    let lib_size = match lib_size {
718        Some(l) => {
719            if l.len() != n_samples {
720                return Err(EdgeErrors::LengthMismatch {
721                    name: "lib_size",
722                    expected: n_samples,
723                    got: l.len(),
724                });
725            }
726            l
727        }
728        None => {
729            derived = column_sums(counts, n_samples);
730            &derived
731        }
732    };
733    if let Some(bad) = lib_size.iter().find(|v| !v.is_finite() || **v <= 0.0) {
734        return Err(EdgeErrors::InvalidArgument(format!(
735            "library sizes must be positive and finite, found {bad}"
736        )));
737    }
738
739    let common_lib_size = (lib_size.iter().map(|l| l.ln()).sum::<f64>() / n_samples as f64).exp();
740
741    // One fit per group, written back into a full n_genes by n_samples layout
742    // so the mapping below is one pass over rows.
743    let mut input_mean = vec![0.0_f64; n_genes * n_samples];
744    let mut output_mean = vec![0.0_f64; n_genes * n_samples];
745    for columns in group_columns(group) {
746        let sub = select_columns(counts, n_genes, n_samples, &columns);
747        let offsets: Vec<f64> = columns.iter().map(|&j| lib_size[j].ln()).collect();
748        let beta = mglm_one_group(
749            &sub,
750            n_genes,
751            columns.len(),
752            dispersion,
753            &Recycled::by_sample(offsets),
754            None,
755            None,
756            None,
757        )?;
758        for (gene, b) in beta.iter().enumerate() {
759            let lambda = b.exp();
760            for &j in &columns {
761                input_mean[gene * n_samples + j] = lambda * lib_size[j];
762                output_mean[gene * n_samples + j] = lambda * common_lib_size;
763            }
764        }
765    }
766
767    let mut pseudo = vec![0.0_f64; n_genes * n_samples];
768    let outcome: Result<(), EdgeErrors> = pseudo
769        .par_chunks_mut(n_samples)
770        .enumerate()
771        .try_for_each(|(gene, row)| {
772            let y = &counts[gene * n_samples..(gene + 1) * n_samples];
773            let im = &input_mean[gene * n_samples..(gene + 1) * n_samples];
774            let om = &output_mean[gene * n_samples..(gene + 1) * n_samples];
775            let d = per_gene[gene];
776            for (j, out) in row.iter_mut().enumerate() {
777                let count = y[j].to_f64().unwrap_or(f64::NAN);
778                *out = q2q_one(count, im[j], om[j], d)?.max(0.0);
779            }
780            Ok(())
781        });
782    outcome?;
783
784    Ok(pseudo)
785}
786
787/////////////////////
788// Exact test core //
789/////////////////////
790
791/// Log of a sum of exponentials, shifted by the maximum.
792///
793/// The convolution sums thousands of masses that can be `e^-800` apiece, so it
794/// is formed in logs, unlike edgeR's natural-scale R loop.
795///
796/// ### Params
797///
798/// * `values` - Log-scale terms. An empty slice sums to `-inf`.
799///
800/// ### Returns
801///
802/// `ln(sum(exp(values)))`.
803fn log_sum_exp(values: &[f64]) -> f64 {
804    let mut max = f64::NEG_INFINITY;
805    for v in values {
806        if *v > max {
807            max = *v;
808        }
809    }
810    if max == f64::NEG_INFINITY {
811        return f64::NEG_INFINITY;
812    }
813    let sum: f64 = values.iter().map(|v| (v - max).exp()).sum();
814    max + sum.ln()
815}
816
817/// Binomial log-PMF, for the Poisson limit of the exact test.
818///
819/// ### Params
820///
821/// * `k` - Number of successes, in `0..=n`
822/// * `n` - Number of trials
823/// * `ln_p` - Log of the success probability
824/// * `ln_q` - Log of one minus the success probability
825///
826/// ### Returns
827///
828/// `ln P(K = k)`.
829#[inline]
830fn binom_ln_pmf(k: f64, n: f64, ln_p: f64, ln_q: f64) -> f64 {
831    ln_gamma(n + 1.0) - ln_gamma(k + 1.0) - ln_gamma(n - k + 1.0) + k * ln_p + (n - k) * ln_q
832}
833
834/// Exact binomial test on one gene's split, edgeR's `binomTest`.
835///
836/// The Poisson limit: at zero dispersion the first group's count given the
837/// total is exactly binomial, so no convolution is needed.
838///
839/// At `p = 0.5` the distribution is symmetric and the answer is twice the
840/// smaller tail. Otherwise it sums every outcome no more probable than the
841/// observed one.
842///
843/// ### Notes
844///
845/// **Departures from edgeR** (see `UPSTREAM_DEVIATIONS.md` B4):
846///
847/// * edgeR switches to a Yates-corrected chi-square above a total of 10000, and
848///   the 2x2 table it builds uses column totals across whichever genes were
849///   passed in, so one gene's answer depends on the others. The enumeration
850///   (`O(total)`) is used at every size here.
851/// * Ties are admitted within [`BINOM_TIE_TOL`], as in R's `binom.test`, not
852///   resolved by `order` on raw masses. edgeR's `dbinom` can put exactly
853///   equiprobable outcomes one ulp apart, so its p-value depends on which was
854///   observed.
855///
856/// ### Params
857///
858/// * `y1` - First group's total, non-negative and integral
859/// * `y2` - Second group's total, non-negative and integral
860/// * `p` - Success probability, strictly inside `(0, 1)`
861///
862/// ### Returns
863///
864/// The two-sided p-value, or [`EdgeErrors`] propagated from [`beta_cdf`].
865fn binom_test_one(y1: f64, y2: f64, p: f64) -> Result<f64, EdgeErrors> {
866    let size = y1 + y2;
867    if size <= 0.0 {
868        return Ok(1.0);
869    }
870    if p == 0.5 {
871        // pbinom(k, n, 0.5) is the regularised incomplete beta I(0.5; n-k, k+1).
872        let k = y1.min(y2);
873        let cdf = beta_cdf(0.5, size - k, k + 1.0)?;
874        return Ok((2.0 * cdf).min(1.0));
875    }
876
877    let n = size as usize;
878    let ln_p = p.ln();
879    let ln_q = (-p).ln_1p();
880    let masses: Vec<f64> = (0..=n)
881        .map(|k| binom_ln_pmf(k as f64, size, ln_p, ln_q).exp())
882        .collect();
883
884    let threshold = masses[y1 as usize] * BINOM_TIE_TOL;
885    let mut kept: Vec<f64> = masses.into_iter().filter(|m| *m <= threshold).collect();
886    // Ascending, as R's `cumsum(d[order(d)])`, so small terms are not lost
887    // under the mode.
888    kept.sort_by(f64::total_cmp);
889    Ok(kept.iter().sum::<f64>().min(1.0))
890}
891
892/// Beta approximation to the double-tail test, edgeR's `exactTestBetaApprox`.
893///
894/// For genes with thousands of reads per group the conditional distribution of
895/// the first group's share is indistinguishable from a beta with matching
896/// moments. Both tails carry a half-count continuity correction and are
897/// compared against the beta median.
898///
899/// The sums are the raw pseudo-counts, not the rounded ones: edgeR decides
900/// *whether* to take this path from the rounded sums, then passes the unrounded
901/// matrix.
902///
903/// ### Params
904///
905/// * `raw1` - First group's unrounded pseudo-count sum, non-negative
906/// * `raw2` - Second group's unrounded pseudo-count sum, non-negative
907/// * `n1` - Number of samples in the first group, at least one
908/// * `n2` - Number of samples in the second group, at least one
909/// * `dispersion` - Dispersion for this gene, non-negative
910///
911/// ### Returns
912///
913/// The two-sided p-value, or [`EdgeErrors`] propagated from the beta routines.
914fn beta_approx_one(
915    raw1: f64,
916    raw2: f64,
917    n1: usize,
918    n2: usize,
919    dispersion: f64,
920) -> Result<f64, EdgeErrors> {
921    let total = raw1 + raw2;
922    if total <= 0.0 {
923        return Ok(1.0);
924    }
925    let mu = total / (n1 + n2) as f64;
926    let alpha1 = n1 as f64 * mu / (1.0 + dispersion * mu);
927    let alpha2 = (n2 as f64 / n1 as f64) * alpha1;
928    let median = beta_ppf(0.5, alpha1, alpha2)?;
929
930    let left = (raw1 + 0.5) / total;
931    let right = (raw1 - 0.5) / total;
932    if left < median {
933        Ok((2.0 * beta_cdf(left, alpha1, alpha2)?).min(1.0))
934    } else if right > median {
935        Ok((2.0 * beta_sf(right, alpha1, alpha2)?).min(1.0))
936    } else {
937        Ok(1.0)
938    }
939}
940
941/// Double-tail exact p-value for one gene, edgeR's `exactTestDoubleTail`.
942///
943/// The p-value is twice the mass in whichever tail the observation falls in.
944///
945/// Three paths, chosen as in edgeR: the exact binomial at zero dispersion, the
946/// beta approximation when both rounded sums exceed `big_count`, and the
947/// convolution otherwise. The convolution is accumulated in logs; edgeR sums
948/// `dnbinom` products directly and silently returns zero once every term
949/// underflows.
950///
951/// ### Params
952///
953/// * `y1` - First group's pseudo-counts for this gene
954/// * `y2` - Second group's pseudo-counts for this gene
955/// * `dispersion` - Dispersion for this gene, non-negative
956/// * `big_count` - Beta approximation threshold
957/// * `buf` - Scratch buffer for the convolution terms, cleared on entry
958///
959/// ### Returns
960///
961/// The p-value, capped at one, or [`EdgeErrors`] propagated from the
962/// distribution routines.
963fn double_tail_one(
964    y1: &[f64],
965    y2: &[f64],
966    dispersion: f64,
967    big_count: f64,
968    buf: &mut Vec<f64>,
969) -> Result<f64, EdgeErrors> {
970    let (n1, n2) = (y1.len(), y2.len());
971    let raw1: f64 = y1.iter().sum();
972    let raw2: f64 = y2.iter().sum();
973    // R's round is half-to-even.
974    let s1 = raw1.round_ties_even();
975    let s2 = raw2.round_ties_even();
976
977    if dispersion <= 0.0 {
978        return binom_test_one(s1, s2, n1 as f64 / (n1 + n2) as f64);
979    }
980    if s1 > big_count && s2 > big_count {
981        return beta_approx_one(raw1, raw2, n1, n2, dispersion);
982    }
983
984    let total = s1 + s2;
985    if total == 0.0 {
986        return Ok(1.0);
987    }
988    let n_total = (n1 + n2) as f64;
989    let mu = total / n_total;
990    let mu1 = n1 as f64 * mu;
991    let mu2 = n2 as f64 * mu;
992    let size1 = n1 as f64 / dispersion;
993    let size2 = n2 as f64 / dispersion;
994    let size_total = n_total / dispersion;
995    // Same success probability everywhere: mu1 / size1, mu2 / size2 and
996    // total / size_total are all the dispersion times the per-sample mean.
997    let prob1 = size1 / (size1 + mu1);
998    let prob2 = size2 / (size2 + mu2);
999    let prob_total = size_total / (size_total + total);
1000
1001    let (lo, hi) = if s1 < mu1 {
1002        (0.0, s1)
1003    } else if s1 > mu1 {
1004        (s1, total)
1005    } else {
1006        return Ok(1.0);
1007    };
1008
1009    buf.clear();
1010    let mut x = lo;
1011    while x <= hi {
1012        buf.push(nbinom_ln_pmf(x, size1, prob1)? + nbinom_ln_pmf(total - x, size2, prob2)?);
1013        x += 1.0;
1014    }
1015    let ln_bottom = nbinom_ln_pmf(total, size_total, prob_total)?;
1016    Ok((LN_2 + log_sum_exp(buf) - ln_bottom).min(0.0).exp())
1017}
1018
1019/// Deviance-based exact p-value for one gene, edgeR's `exactTestByDeviance`.
1020///
1021/// The region is every split of the total whose NB deviance is at least the
1022/// observed one. It differs from the double-tail test only when the groups have
1023/// different sizes.
1024///
1025/// The scan walks in from both ends and stops at the first split inside the
1026/// acceptance region, as edgeR's C++ does. This relies on the deviance being
1027/// unimodal in the split.
1028///
1029/// The common success probability cancels in the mass ratio; edgeR's explicit
1030/// form is kept for ease of checking.
1031///
1032/// ### Params
1033///
1034/// * `y1` - First group's pseudo-counts for this gene
1035/// * `y2` - Second group's pseudo-counts for this gene
1036/// * `dispersion` - Dispersion for this gene, strictly positive
1037/// * `buf` - Scratch buffer for the retained terms, cleared on entry
1038///
1039/// ### Returns
1040///
1041/// The p-value, capped at one, or [`EdgeErrors`] propagated from
1042/// [`nbinom_ln_pmf`].
1043fn deviance_one(
1044    y1: &[f64],
1045    y2: &[f64],
1046    dispersion: f64,
1047    buf: &mut Vec<f64>,
1048) -> Result<f64, EdgeErrors> {
1049    let (n1, n2) = (y1.len(), y2.len());
1050    let s1: f64 = y1.iter().sum::<f64>().round_ties_even();
1051    let s2: f64 = y2.iter().sum::<f64>().round_ties_even();
1052
1053    if dispersion <= 0.0 {
1054        return binom_test_one(s1, s2, n1 as f64 / (n1 + n2) as f64);
1055    }
1056    let total = s1 + s2;
1057    if total == 0.0 {
1058        return Ok(1.0);
1059    }
1060
1061    let n_total = (n1 + n2) as f64;
1062    let mu = total / n_total;
1063    let mu1 = n1 as f64 * mu;
1064    let mu2 = n2 as f64 * mu;
1065    let size1 = n1 as f64 / dispersion;
1066    let size2 = n2 as f64 / dispersion;
1067    let phi1 = 1.0 / size1;
1068    let phi2 = 1.0 / size2;
1069    let prob = size1 / (size1 + mu1);
1070
1071    let observed = unit_nb_deviance(s1, mu1, phi1) + unit_nb_deviance(s2, mu2, phi2);
1072    buf.clear();
1073
1074    // From the left: splits with the first group small.
1075    let mut j = 0.0_f64;
1076    while j <= total {
1077        if observed > unit_nb_deviance(j, mu1, phi1) + unit_nb_deviance(total - j, mu2, phi2) {
1078            break;
1079        }
1080        buf.push(nbinom_ln_pmf(j, size1, prob)? + nbinom_ln_pmf(total - j, size2, prob)?);
1081        j += 1.0;
1082    }
1083    // From the right, over whatever the left scan did not already cover.
1084    let mut k = 0.0_f64;
1085    while k <= total - j {
1086        if observed > unit_nb_deviance(k, mu2, phi2) + unit_nb_deviance(total - k, mu1, phi1) {
1087            break;
1088        }
1089        buf.push(nbinom_ln_pmf(k, size2, prob)? + nbinom_ln_pmf(total - k, size1, prob)?);
1090        k += 1.0;
1091    }
1092
1093    let size_total = size1 + size2;
1094    let ln_bottom = nbinom_ln_pmf(total, size_total, size_total / (size_total + mu1 + mu2))?;
1095    Ok((log_sum_exp(buf) - ln_bottom).min(0.0).exp())
1096}
1097
1098/// Small-probability exact p-value for one gene, edgeR's `exactTestBySmallP`.
1099///
1100/// The region is every split no more probable than the observed one. It is the
1101/// most expensive region (every split is evaluated) and differs from the
1102/// double-tail test only when the groups have different sizes.
1103///
1104/// ### One departure from edgeR
1105///
1106/// `exactTestBySmallP` ends on `min(pvals, 1)`, which in R reduces to a scalar:
1107/// every gene returns the smallest p-value in the matrix. This caps each gene's
1108/// own value (`pmin`), and the fixtures call edgeR one gene at a time. See
1109/// `UPSTREAM_DEVIATIONS.md` B3.
1110///
1111/// ### Params
1112///
1113/// * `y1` - First group's pseudo-counts for this gene
1114/// * `y2` - Second group's pseudo-counts for this gene
1115/// * `dispersion` - Dispersion for this gene, strictly positive
1116/// * `buf` - Scratch buffer for the retained terms, cleared on entry
1117///
1118/// ### Returns
1119///
1120/// The p-value, capped at one, or [`EdgeErrors`] propagated from
1121/// [`nbinom_ln_pmf`].
1122fn small_p_one(
1123    y1: &[f64],
1124    y2: &[f64],
1125    dispersion: f64,
1126    buf: &mut Vec<f64>,
1127) -> Result<f64, EdgeErrors> {
1128    let (n1, n2) = (y1.len(), y2.len());
1129    let s1: f64 = y1.iter().sum::<f64>().round_ties_even();
1130    let s2: f64 = y2.iter().sum::<f64>().round_ties_even();
1131
1132    if dispersion <= 0.0 {
1133        return binom_test_one(s1, s2, n1 as f64 / (n1 + n2) as f64);
1134    }
1135    let total = s1 + s2;
1136    if total == 0.0 {
1137        return Ok(1.0);
1138    }
1139
1140    let n_total = (n1 + n2) as f64;
1141    let mu = total / n_total;
1142    let size1 = n1 as f64 / dispersion;
1143    let size2 = n2 as f64 / dispersion;
1144    let prob = size1 / (size1 + n1 as f64 * mu);
1145
1146    let ln_observed = nbinom_ln_pmf(s1, size1, prob)? + nbinom_ln_pmf(s2, size2, prob)?;
1147    buf.clear();
1148    let mut x = 0.0_f64;
1149    while x <= total {
1150        let term = nbinom_ln_pmf(x, size1, prob)? + nbinom_ln_pmf(total - x, size2, prob)?;
1151        if term <= ln_observed {
1152            buf.push(term);
1153        }
1154        x += 1.0;
1155    }
1156
1157    let size_total = size1 + size2;
1158    let ln_bottom = nbinom_ln_pmf(total, size_total, size_total / (size_total + total))?;
1159    Ok((log_sum_exp(buf) - ln_bottom).min(0.0).exp())
1160}
1161
1162/// Runs one of the three rejection regions over every gene.
1163///
1164/// Each thread keeps one convolution buffer, grown to the largest total it sees.
1165///
1166/// [`RejectionRegion::Deviance`] and [`RejectionRegion::SmallP`] fall back to
1167/// the double-tail test when the groups have equal size, as in edgeR.
1168///
1169/// ### Params
1170///
1171/// * `y1` - First group's pseudo-counts, row-major `n_genes * n1`
1172/// * `y2` - Second group's pseudo-counts, row-major `n_genes * n2`
1173/// * `n_genes` - Number of genes
1174/// * `n1` - Number of samples in the first group
1175/// * `n2` - Number of samples in the second group
1176/// * `dispersion` - One dispersion per gene, non-negative
1177/// * `region` - Which rejection region to sum over
1178/// * `big_count` - Beta approximation threshold, double tail only
1179///
1180/// ### Returns
1181///
1182/// One p-value per gene, or the first [`EdgeErrors`] any gene produced.
1183#[allow(clippy::too_many_arguments)]
1184fn exact_pvalues(
1185    y1: &[f64],
1186    y2: &[f64],
1187    n_genes: usize,
1188    n1: usize,
1189    n2: usize,
1190    dispersion: &[f64],
1191    region: RejectionRegion,
1192    big_count: f64,
1193) -> Result<Vec<f64>, EdgeErrors> {
1194    let region = if n1 == n2 {
1195        RejectionRegion::DoubleTail
1196    } else {
1197        region
1198    };
1199
1200    let mut out = vec![1.0_f64; n_genes];
1201    let outcome: Result<(), EdgeErrors> =
1202        out.par_iter_mut()
1203            .enumerate()
1204            .try_for_each_init(Vec::<f64>::new, |buf, (gene, p)| {
1205                let row1 = &y1[gene * n1..(gene + 1) * n1];
1206                let row2 = &y2[gene * n2..(gene + 1) * n2];
1207                let d = dispersion[gene];
1208                *p = match region {
1209                    RejectionRegion::DoubleTail => double_tail_one(row1, row2, d, big_count, buf)?,
1210                    RejectionRegion::Deviance => deviance_one(row1, row2, d, buf)?,
1211                    RejectionRegion::SmallP => small_p_one(row1, row2, d, buf)?,
1212                };
1213                Ok(())
1214            });
1215    outcome?;
1216    Ok(out)
1217}
1218
1219/// Fits the abundance of one group with prior counts added.
1220///
1221/// The prior is scaled by each library size relative to the mean over the pair
1222/// and added to the counts; the offset becomes `log(lib + 2 * prior)` so the
1223/// prior does not move the fitted level.
1224///
1225/// ### Params
1226///
1227/// * `counts` - This group's counts, row-major `n_genes * n_group`
1228/// * `n_genes` - Number of genes
1229/// * `lib` - Effective library size per sample of this group
1230/// * `mean_lib` - Mean effective library size over *both* groups
1231/// * `prior_count` - Prior count before scaling
1232/// * `dispersion` - Dispersion, recycled over genes
1233///
1234/// ### Returns
1235///
1236/// One log-scale abundance per gene, or whatever [`mglm_one_group`] propagates.
1237fn augmented_abundance(
1238    counts: &[f64],
1239    n_genes: usize,
1240    lib: &[f64],
1241    mean_lib: f64,
1242    prior_count: f64,
1243    dispersion: &Recycled<f64>,
1244) -> Result<Vec<f64>, EdgeErrors> {
1245    let n_group = lib.len();
1246    let prior: Vec<f64> = lib.iter().map(|l| prior_count * l / mean_lib).collect();
1247    let offset: Vec<f64> = lib
1248        .iter()
1249        .zip(prior.iter())
1250        .map(|(l, p)| (l + 2.0 * p).ln())
1251        .collect();
1252
1253    let mut augmented = vec![0.0_f64; n_genes * n_group];
1254    augmented
1255        .par_chunks_mut(n_group)
1256        .enumerate()
1257        .for_each(|(gene, row)| {
1258            let src = &counts[gene * n_group..(gene + 1) * n_group];
1259            for (j, v) in row.iter_mut().enumerate() {
1260                *v = src[j] + prior[j];
1261            }
1262        });
1263
1264    mglm_one_group(
1265        &augmented,
1266        n_genes,
1267        n_group,
1268        dispersion,
1269        &Recycled::by_sample(offset),
1270        None,
1271        None,
1272        None,
1273    )
1274}
1275
1276/// Joins two per-group matrices, first group's columns first.
1277///
1278/// ### Params
1279///
1280/// * `y1` - First group's counts, row-major `n_genes * n1`
1281/// * `y2` - Second group's counts, row-major `n_genes * n2`
1282/// * `n_genes` - Number of genes
1283/// * `n1` - Number of samples in the first group
1284/// * `n2` - Number of samples in the second group
1285///
1286/// ### Returns
1287///
1288/// A row-major `n_genes * (n1 + n2)` matrix.
1289fn interleave(y1: &[f64], y2: &[f64], n_genes: usize, n1: usize, n2: usize) -> Vec<f64> {
1290    let mut out = vec![0.0_f64; n_genes * (n1 + n2)];
1291    out.par_chunks_mut(n1 + n2)
1292        .enumerate()
1293        .for_each(|(gene, row)| {
1294            row[..n1].copy_from_slice(&y1[gene * n1..(gene + 1) * n1]);
1295            row[n1..].copy_from_slice(&y2[gene * n2..(gene + 1) * n2]);
1296        });
1297    out
1298}
1299
1300///////////////
1301// Front end //
1302///////////////
1303
1304/// Exact test for differential expression between two groups, edgeR's
1305/// `exactTest`.
1306///
1307/// Per gene, three things from three places:
1308///
1309/// * the log2 fold change, from one intercept-only NB GLM per group with a small
1310///   prior count;
1311/// * the average log2-CPM, from `aveLogCPM` over the whole object, including
1312///   samples outside the pair;
1313/// * the p-value, from pseudo-counts equalised onto a common library size and
1314///   summed over a rejection region.
1315///
1316/// The pseudo-counts follow `exactTest`, not [`equalize_lib_sizes`]: one pooled
1317/// fit across both groups, and the common library size is the geometric mean
1318/// over the pair only.
1319///
1320/// ### Params
1321///
1322/// * `y` - Counts and the per-sample quantities. Must carry a `group`.
1323/// * `pair` - The two group labels, `(first, second)`. The fold change is
1324///   second over first, as edgeR's `pair = c(a, b)`. Samples in neither group
1325///   are dropped for the test but still count towards the log-CPM.
1326/// * `dispersion` - Dispersion, recycled over genes. `None` takes the most
1327///   structured estimate on `y`, as edgeR's `dispersion = "auto"`.
1328/// * `params` - Tuning knobs, or [`ExactTestParams::default`]
1329///
1330/// ### Returns
1331///
1332/// The three columns, one entry per gene in the input order. Errors are
1333/// [`EdgeErrors::InvalidArgument`] for a missing group, a degenerate pair or a
1334/// group with no samples, [`EdgeErrors::InvalidDispersion`] if none is available
1335/// or one is negative, and whatever the GLM fit and the distribution routines
1336/// propagate.
1337///
1338/// ### References
1339///
1340/// Robinson and Smyth, Bioinformatics 23(21), 2007
1341pub fn exact_test<T: EdgeFloat>(
1342    y: &DgeList<T>,
1343    pair: (usize, usize),
1344    dispersion: Option<&Recycled<f64>>,
1345    params: Option<ExactTestParams>,
1346) -> Result<ExactTestResult, EdgeErrors> {
1347    let params = params.unwrap_or_default();
1348    if !params.prior_count.is_finite() || params.prior_count < 0.0 {
1349        return Err(EdgeErrors::InvalidArgument(format!(
1350            "prior_count must be non-negative and finite, got {}",
1351            params.prior_count
1352        )));
1353    }
1354    if pair.0 == pair.1 {
1355        return Err(EdgeErrors::InvalidArgument(format!(
1356            "pair must name two different groups; got ({}, {})",
1357            pair.0, pair.1
1358        )));
1359    }
1360
1361    let group = y.group.as_ref().ok_or_else(|| {
1362        EdgeErrors::InvalidArgument("the exact test needs a group per sample".to_string())
1363    })?;
1364
1365    let owned;
1366    let dispersion = match dispersion {
1367        Some(d) => d,
1368        None => {
1369            owned = y
1370                .dispersion()
1371                .ok_or(EdgeErrors::InvalidDispersion(f64::NAN))?
1372                .0;
1373            &owned
1374        }
1375    };
1376    let per_gene = per_gene_dispersion(dispersion, y.n_genes)?;
1377
1378    let columns1: Vec<usize> = (0..y.n_samples).filter(|&j| group[j] == pair.0).collect();
1379    let columns2: Vec<usize> = (0..y.n_samples).filter(|&j| group[j] == pair.1).collect();
1380    if columns1.is_empty() || columns2.is_empty() {
1381        return Err(EdgeErrors::InvalidArgument(format!(
1382            "both groups of the pair need at least one sample; got {} and {}",
1383            columns1.len(),
1384            columns2.len()
1385        )));
1386    }
1387    let (n1, n2) = (columns1.len(), columns2.len());
1388
1389    let effective = y.norm_lib_sizes();
1390    if let Some(bad) = effective.iter().find(|v| !v.is_finite() || **v <= 0.0) {
1391        return Err(EdgeErrors::InvalidArgument(format!(
1392            "library sizes must be positive and finite, found {bad}"
1393        )));
1394    }
1395    let lib1: Vec<f64> = columns1.iter().map(|&j| effective[j]).collect();
1396    let lib2: Vec<f64> = columns2.iter().map(|&j| effective[j]).collect();
1397
1398    let n_pair = n1 + n2;
1399    let mean_lib = (lib1.iter().sum::<f64>() + lib2.iter().sum::<f64>()) / n_pair as f64;
1400    let ln_sum: f64 = lib1.iter().chain(lib2.iter()).map(|l| l.ln()).sum::<f64>();
1401    let lib_size_average = (ln_sum / n_pair as f64).exp();
1402
1403    let y1 = select_columns(&y.counts, y.n_genes, y.n_samples, &columns1);
1404    let y2 = select_columns(&y.counts, y.n_genes, y.n_samples, &columns2);
1405
1406    // Fold change: one intercept-only fit per group on prior-augmented counts.
1407    let abundance1 = augmented_abundance(
1408        &y1,
1409        y.n_genes,
1410        &lib1,
1411        mean_lib,
1412        params.prior_count,
1413        dispersion,
1414    )?;
1415    let abundance2 = augmented_abundance(
1416        &y2,
1417        y.n_genes,
1418        &lib2,
1419        mean_lib,
1420        params.prior_count,
1421        dispersion,
1422    )?;
1423    let log_fc: Vec<f64> = abundance1
1424        .iter()
1425        .zip(abundance2.iter())
1426        .map(|(a1, a2)| (a2 - a1) / LN_2)
1427        .collect();
1428
1429    // Pseudo-counts: one pooled fit, then the quantile map onto the geometric
1430    // mean library size.
1431    let pooled = interleave(&y1, &y2, y.n_genes, n1, n2);
1432    let pooled_offset: Vec<f64> = lib1.iter().chain(lib2.iter()).map(|l| l.ln()).collect();
1433    let abundance = mglm_one_group(
1434        &pooled,
1435        y.n_genes,
1436        n_pair,
1437        dispersion,
1438        &Recycled::by_sample(pooled_offset),
1439        None,
1440        None,
1441        None,
1442    )?;
1443
1444    let mut eq1 = vec![0.0_f64; y.n_genes * n1];
1445    let mut eq2 = vec![0.0_f64; y.n_genes * n2];
1446    let outcome: Result<(), EdgeErrors> = eq1
1447        .par_chunks_mut(n1)
1448        .zip(eq2.par_chunks_mut(n2))
1449        .enumerate()
1450        .try_for_each(|(gene, (row1, row2))| {
1451            let rate = abundance[gene].exp();
1452            let d = per_gene[gene];
1453            let output_mean = rate * lib_size_average;
1454            for (j, out) in row1.iter_mut().enumerate() {
1455                *out = q2q_one(y1[gene * n1 + j], rate * lib1[j], output_mean, d)?;
1456            }
1457            for (j, out) in row2.iter_mut().enumerate() {
1458                *out = q2q_one(y2[gene * n2 + j], rate * lib2[j], output_mean, d)?;
1459            }
1460            Ok(())
1461        });
1462    outcome?;
1463
1464    let p_value = exact_pvalues(
1465        &eq1,
1466        &eq2,
1467        y.n_genes,
1468        n1,
1469        n2,
1470        &per_gene,
1471        params.rejection_region,
1472        params.big_count,
1473    )?;
1474
1475    let log_cpm = match &y.ave_log_cpm {
1476        Some(v) => {
1477            if v.len() != y.n_genes {
1478                return Err(EdgeErrors::LengthMismatch {
1479                    name: "ave_log_cpm",
1480                    expected: y.n_genes,
1481                    got: v.len(),
1482                });
1483            }
1484            v.clone()
1485        }
1486        None => {
1487            // As edgeR's aveLogCPM.DGEList: the *common* dispersion and every
1488            // sample, not the pair.
1489            let alc_dispersion = y.common_dispersion.map(Recycled::scalar);
1490            ave_log_cpm(
1491                &y.counts,
1492                y.n_genes,
1493                y.n_samples,
1494                Some(&effective),
1495                None,
1496                AVE_LOG_CPM_PRIOR,
1497                alc_dispersion.as_ref(),
1498            )?
1499        }
1500    };
1501
1502    Ok(ExactTestResult {
1503        log_fc,
1504        log_cpm,
1505        p_value,
1506    })
1507}
1508
1509///////////
1510// Tests //
1511///////////
1512
1513#[cfg(test)]
1514mod tests {
1515    // References are pasted verbatim from R's 17-digit output so they stay
1516    // checkable against the `Rscript` line above each test.
1517    #![allow(clippy::excessive_precision)]
1518
1519    use super::*;
1520    use approx::assert_relative_eq;
1521
1522    /// Agreement target against edgeR.
1523    ///
1524    /// Looser than what is achieved, since two of the three numbers come from
1525    /// an iterative fit.
1526    ///
1527    /// Achieved over every fixture here: p-values, pseudo-counts and quantile
1528    /// maps hold to 1e-12 relative or better; log fold changes to 2e-11, which
1529    /// is [`mglm_one_group`]'s own `tol = 1e-10` on the coefficient step.
1530    const TOL: f64 = 1e-9;
1531
1532    /// 6 genes by 6 samples. Gene 4 is zero in the first group, gene 5 is above
1533    /// `big_count` in both, and gene 6 is flat.
1534    /// ```r
1535    /// y <- matrix(c(10,12,11,40,44,38, 50,48,52,49,51,50, 2,0,5,1,3,0,
1536    ///               0,0,0,7,9,8, 1200,1100,1300,2400,2500,2300, 5,7,6,5,6,7),
1537    ///             nrow = 6, byrow = TRUE)
1538    /// ls <- c(1e6,1.2e6,0.9e6,1.1e6,1e6,1.3e6)
1539    /// nf <- c(0.95,1.05,1.0,1.1,0.9,1.0)
1540    /// disp <- c(0.05,0.1,0.2,0.15,0.08,0.12)
1541    /// ```
1542    #[rustfmt::skip]
1543    const COUNTS: [f64; 36] = [
1544        10.0, 12.0, 11.0, 40.0, 44.0, 38.0,
1545        50.0, 48.0, 52.0, 49.0, 51.0, 50.0,
1546        2.0, 0.0, 5.0, 1.0, 3.0, 0.0,
1547        0.0, 0.0, 0.0, 7.0, 9.0, 8.0,
1548        1200.0, 1100.0, 1300.0, 2400.0, 2500.0, 2300.0,
1549        5.0, 7.0, 6.0, 5.0, 6.0, 7.0,
1550    ];
1551
1552    const LIB: [f64; 6] = [1e6, 1.2e6, 0.9e6, 1.1e6, 1e6, 1.3e6];
1553    const NF: [f64; 6] = [0.95, 1.05, 1.0, 1.1, 0.9, 1.0];
1554    const DISP: [f64; 6] = [0.05, 0.1, 0.2, 0.15, 0.08, 0.12];
1555
1556    /// Builds the fixture with the library sizes and factors R was given.
1557    ///
1558    /// ### Params
1559    ///
1560    /// * `group` - One label per sample
1561    ///
1562    /// ### Returns
1563    ///
1564    /// The container, matching `DGEList(counts=y, group=group, lib.size=ls,
1565    /// norm.factors=nf)`.
1566    fn fixture(group: Vec<usize>) -> DgeList<f64> {
1567        let mut y = DgeList::new(COUNTS.to_vec(), 6, 6, Some(group)).unwrap();
1568        y.lib_size = LIB.to_vec();
1569        y.norm_factors = NF.to_vec();
1570        y
1571    }
1572
1573    /// Compares against a reference at [`TOL`].
1574    ///
1575    /// ### Params
1576    ///
1577    /// * `got` - Values produced here
1578    /// * `want` - Reference values from edgeR
1579    fn assert_close(got: &[f64], want: &[f64]) {
1580        assert_eq!(got.len(), want.len());
1581        for (g, w) in got.iter().zip(want.iter()) {
1582            assert_relative_eq!(g, w, epsilon = 1e-12, max_relative = TOL);
1583        }
1584    }
1585
1586    ///////////////
1587    // q2qnbinom //
1588    ///////////////
1589
1590    /// `x <- c(0,1,5,20,100,900,3,0.5)`
1591    /// `im <- c(2,2,10,10,50,500,3,1e-16)`
1592    /// `om <- c(4,4,20,20,25,800,3,1e-16)`
1593    const Q2Q_X: [f64; 8] = [0.0, 1.0, 5.0, 20.0, 100.0, 900.0, 3.0, 0.5];
1594    const Q2Q_IM: [f64; 8] = [2.0, 2.0, 10.0, 10.0, 50.0, 500.0, 3.0, 1e-16];
1595    const Q2Q_OM: [f64; 8] = [4.0, 4.0, 20.0, 20.0, 25.0, 800.0, 3.0, 1e-16];
1596
1597    /// `cat(format(edgeR:::q2qnbinom(x, im, om, dispersion=0.1), digits=17), sep=", ")`
1598    #[test]
1599    fn test_q2q_nbinom_matches_edger_at_dispersion_one_tenth() {
1600        let want = [
1601            0.47247476834805369,
1602            2.48762930404220217,
1603            11.26359605754503690,
1604            37.16149924300670193,
1605            52.18843930258167774,
1606            1437.48127962309013128,
1607            3.00000000000000000,
1608            0.50000000000000011,
1609        ];
1610        let got = q2q_nbinom(&Q2Q_X, &Q2Q_IM, &Q2Q_OM, 0.1).unwrap();
1611        assert_close(&got, &want);
1612    }
1613
1614    /// `cat(format(edgeR:::q2qnbinom(x, im, om, dispersion=0), digits=17), sep=", ")`
1615    #[test]
1616    fn test_q2q_nbinom_matches_edger_at_zero_dispersion() {
1617        let want = [
1618            0.58578643762690552,
1619            2.59345105979179325,
1620            12.74306177448739064,
1621            33.76355118458810267,
1622            61.97302934441376010,
1623            1295.83915428995442198,
1624            3.00000000000000000,
1625            0.50000000000000011,
1626        ];
1627        let got = q2q_nbinom(&Q2Q_X, &Q2Q_IM, &Q2Q_OM, 0.0).unwrap();
1628        assert_close(&got, &want);
1629    }
1630
1631    /// `cat(format(edgeR:::q2qnbinom(x, im, om, dispersion=1.5), digits=17), sep=", ")`
1632    #[test]
1633    fn test_q2q_nbinom_matches_edger_at_a_large_dispersion() {
1634        let want = [
1635            0.12917130661302939,
1636            2.17053597295496514,
1637            10.19528650723413499,
1638            39.77185077921592438,
1639            50.12063954091983220,
1640            1439.89519676942063597,
1641            3.00000000000000044,
1642            0.49999999999999989,
1643        ];
1644        let got = q2q_nbinom(&Q2Q_X, &Q2Q_IM, &Q2Q_OM, 1.5).unwrap();
1645        assert_close(&got, &want);
1646    }
1647
1648    /// The branch that needs a log-space upper incomplete gamma. At dispersion
1649    /// 0.001 the first element sits at `Q = e^-1605`, a flat zero on the natural
1650    /// scale.
1651    ///
1652    /// `cat(format(edgeR:::q2qnbinom(c(6000,6000,2000), c(1000,200,40),`
1653    /// `c(1500,400,55), dispersion=0.001), digits=17), sep=", ")`
1654    #[test]
1655    fn test_q2q_nbinom_survives_the_far_upper_tail() {
1656        let x = [6000.0, 6000.0, 2000.0];
1657        let im = [1000.0, 200.0, 40.0];
1658        let om = [1500.0, 400.0, 55.0];
1659
1660        let want = [8186.4210997511691, 8404.0259444343410, 2227.2071162950324];
1661        assert_close(&q2q_nbinom(&x, &im, &om, 0.001).unwrap(), &want);
1662
1663        // `dispersion = 0.05`
1664        let want = [8968.9264289783969, 11625.9467408884921, 2572.2470886452957];
1665        assert_close(&q2q_nbinom(&x, &im, &om, 0.05).unwrap(), &want);
1666    }
1667
1668    /// `cat(format(edgeR:::q2qnbinom(c(0,0,1), c(1000,200,40), c(1500,400,55),`
1669    /// `dispersion=0.001), digits=17), sep=", ")` and the `0.05` version.
1670    #[test]
1671    fn test_q2q_nbinom_matches_edger_in_the_far_lower_tail() {
1672        let x = [0.0, 0.0, 1.0];
1673        let im = [1000.0, 200.0, 40.0];
1674        let om = [1500.0, 400.0, 55.0];
1675
1676        let want = [65.3468031185425104, 47.2474768348053402, 5.9130723310523443];
1677        assert_close(&q2q_nbinom(&x, &im, &om, 0.001).unwrap(), &want);
1678
1679        let want = [2.4549984035979393, 4.5983158163212074, 2.8240360871062333];
1680        assert_close(&q2q_nbinom(&x, &im, &om, 0.05).unwrap(), &want);
1681    }
1682
1683    /// Equal means must map a count onto itself.
1684    /// `cat(format(edgeR:::q2qnbinom(c(0,3,17,900), rep(5,4), rep(5,4),`
1685    /// `dispersion=0.1), digits=17), sep=", ")`
1686    #[test]
1687    fn test_q2q_nbinom_is_the_identity_when_the_means_agree() {
1688        let x = [0.0, 3.0, 17.0, 900.0];
1689        let means = [5.0_f64; 4];
1690        let got = q2q_nbinom(&x, &means, &means, 0.1).unwrap();
1691        // R reports 1.5365178734079724e-16 for the zero: its own gamma round
1692        // trip rounding.
1693        for (g, w) in got.iter().zip(x.iter()) {
1694            assert_relative_eq!(g, w, epsilon = 1e-9, max_relative = TOL);
1695        }
1696    }
1697
1698    /// A mean below `eps` nudges both sides by a quarter, keeping the identity.
1699    #[test]
1700    fn test_q2q_nbinom_nudges_a_vanishing_mean() {
1701        let got = q2q_nbinom(&[0.5], &[1e-16], &[1e-16], 0.1).unwrap();
1702        assert_relative_eq!(got[0], 0.5, epsilon = 1e-12);
1703    }
1704
1705    #[test]
1706    fn test_q2q_nbinom_rejects_bad_input() {
1707        assert!(matches!(
1708            q2q_nbinom(&[1.0], &[1.0, 2.0], &[1.0], 0.1),
1709            Err(EdgeErrors::LengthMismatch { .. })
1710        ));
1711        assert!(matches!(
1712            q2q_nbinom(&[1.0], &[1.0], &[1.0, 2.0], 0.1),
1713            Err(EdgeErrors::LengthMismatch { .. })
1714        ));
1715        assert!(matches!(
1716            q2q_nbinom(&[1.0], &[1.0], &[1.0], -0.1),
1717            Err(EdgeErrors::InvalidDispersion(_))
1718        ));
1719        assert!(matches!(
1720            q2q_nbinom(&[1.0], &[1.0], &[1.0], f64::NAN),
1721            Err(EdgeErrors::InvalidDispersion(_))
1722        ));
1723        assert!(matches!(
1724            q2q_nbinom(&[-1.0], &[1.0], &[1.0], 0.1),
1725            Err(EdgeErrors::InvalidArgument(_))
1726        ));
1727        assert!(matches!(
1728            q2q_nbinom(&[1.0], &[-1.0], &[1.0], 0.1),
1729            Err(EdgeErrors::InvalidArgument(_))
1730        ));
1731        assert!(matches!(
1732            q2q_nbinom(&[1.0], &[1.0], &[-1.0], 0.1),
1733            Err(EdgeErrors::InvalidArgument(_))
1734        ));
1735    }
1736
1737    /// The log-space upper incomplete gamma against the natural-scale one it
1738    /// replaces, over the range where both are valid.
1739    #[test]
1740    fn test_ln_reg_gamma_upper_agrees_with_the_natural_scale() {
1741        for &(a, x) in &[
1742            (0.5_f64, 0.1_f64),
1743            (1.0, 0.5),
1744            (2.0, 3.0),
1745            (5.0, 5.0),
1746            (5.0, 40.0),
1747            (14.3, 285.0),
1748            (100.0, 120.0),
1749            (500.0, 600.0),
1750        ] {
1751            let direct = chisq_sf(2.0 * x, 2.0 * a).unwrap();
1752            let logged = ln_reg_gamma_upper(a, x).unwrap();
1753            assert_relative_eq!(logged.exp(), direct, max_relative = 1e-11);
1754        }
1755    }
1756
1757    /// The inverse round-trips to the last few bits, including where the tail
1758    /// probability is unrepresentable.
1759    #[test]
1760    fn test_ln_reg_gamma_upper_inv_round_trips() {
1761        for &a in &[0.5_f64, 1.0, 3.0, 20.0, 500.0] {
1762            for &ln_q in &[-1e-3_f64, -0.7, -5.0, -50.0, -400.0, -1600.0] {
1763                let x = ln_reg_gamma_upper_inv(ln_q, a).unwrap();
1764                assert_relative_eq!(ln_reg_gamma_upper(a, x).unwrap(), ln_q, max_relative = 1e-9);
1765            }
1766        }
1767    }
1768
1769    ////////////////////////
1770    // equalize_lib_sizes //
1771    ////////////////////////
1772
1773    /// `m <- matrix(c(10,12,11,40,44,38, 50,48,52,49,51,50, 2,0,5,1,3,0),`
1774    /// `nrow=3, byrow=TRUE)`
1775    /// `lsx <- c(1e6,1.2e6,0.9e6,1.1e6,1e6,1.3e6)`
1776    #[rustfmt::skip]
1777    const EQ_COUNTS: [f64; 18] = [
1778        10.0, 12.0, 11.0, 40.0, 44.0, 38.0,
1779        50.0, 48.0, 52.0, 49.0, 51.0, 50.0,
1780        2.0, 0.0, 5.0, 1.0, 3.0, 0.0,
1781    ];
1782
1783    /// `eq <- equalizeLibSizes(m, group=c(1,1,1,2,2,2), dispersion=c(0.05,0.1,0.2),`
1784    /// `lib.size=lsx); cat(format(t(eq$pseudo.counts), digits=17), sep=", ")`
1785    #[test]
1786    fn test_equalize_lib_sizes_matches_edger_with_a_tagwise_dispersion() {
1787        let want = [
1788            10.77396968053937343,
1789            10.71756939170353817,
1790            13.06148638116767380,
1791            39.09381908827660368,
1792            47.20616010342799029,
1793            31.18877655466048182,
1794            53.75710417620476989,
1795            42.91780303131315577,
1796            61.99137485252995816,
1797            47.89006600316915296,
1798            54.79292199365470140,
1799            41.23952895466592850,
1800            2.16482265343957536,
1801            0.00000000000000000,
1802            5.76615714775634647,
1803            0.97296861050989225,
1804            3.17215790436234890,
1805            0.00000000000000000,
1806        ];
1807        let dispersion = Recycled::by_gene(vec![0.05, 0.1, 0.2]);
1808        let got = equalize_lib_sizes(
1809            &EQ_COUNTS,
1810            3,
1811            6,
1812            Some(&[1, 1, 1, 2, 2, 2]),
1813            &dispersion,
1814            Some(&LIB),
1815        )
1816        .unwrap();
1817        assert_close(&got, &want);
1818
1819        // `cat(format(eq$pseudo.lib.size, digits=17))` -> 1075127.4874964589
1820        let common = (LIB.iter().map(|l| l.ln()).sum::<f64>() / 6.0).exp();
1821        assert_relative_eq!(common, 1075127.4874964589, max_relative = 1e-12);
1822    }
1823
1824    /// `eq2 <- equalizeLibSizes(m, group=NULL, dispersion=0.05, lib.size=lsx)`
1825    #[test]
1826    fn test_equalize_lib_sizes_treats_a_missing_group_as_one_group() {
1827        let want = [
1828            10.96008842033811348,
1829            10.44646960127512614,
1830            13.59712013549795273,
1831            39.16595939215174837,
1832            46.92801915579765648,
1833            31.65690822951201966,
1834            53.72547236839000107,
1835            42.89019621190085019,
1836            61.80956593706329016,
1837            47.88264153989446470,
1838            54.78906141484367254,
1839            41.11794764355339282,
1840            2.14673596145325218,
1841            0.00000000000000000,
1842            5.61776154772958769,
1843            0.96792622133193928,
1844            3.18430182459055366,
1845            0.00000000000000000,
1846        ];
1847        let dispersion = Recycled::scalar(0.05);
1848        let got = equalize_lib_sizes(&EQ_COUNTS, 3, 6, None, &dispersion, Some(&LIB)).unwrap();
1849        assert_close(&got, &want);
1850    }
1851
1852    /// `eq3 <- equalizeLibSizes(m, group=c(1,1,1,2,2,2), dispersion=0.05,`
1853    /// `lib.size=NULL)`. Without library sizes the fitted means fall under `eps`
1854    /// and the quarter-count nudge shows.
1855    #[test]
1856    fn test_equalize_lib_sizes_falls_back_to_the_column_sums() {
1857        let want = [
1858            12.37402438212434674,
1859            15.12339607660574714,
1860            12.37382629390799593,
1861            33.87607295258574425,
1862            34.23385182961500561,
1863            32.88971374206387566,
1864            61.46657328525719777,
1865            60.98805703840496051,
1866            58.33745565015052392,
1867            41.49462344661865387,
1868            39.58747634774626079,
1869            43.34659093904294025,
1870            2.50600579756254671,
1871            0.14125440085394836,
1872            5.46619301702612859,
1873            0.81569267802383960,
1874            2.47919721221450740,
1875            0.00000000000000000,
1876        ];
1877        let dispersion = Recycled::scalar(0.05);
1878        let got = equalize_lib_sizes(
1879            &EQ_COUNTS,
1880            3,
1881            6,
1882            Some(&[1, 1, 1, 2, 2, 2]),
1883            &dispersion,
1884            None,
1885        )
1886        .unwrap();
1887        assert_close(&got, &want);
1888    }
1889
1890    #[test]
1891    fn test_equalize_lib_sizes_rejects_bad_input() {
1892        let d = Recycled::scalar(0.05);
1893        assert!(matches!(
1894            equalize_lib_sizes::<f64>(&[], 0, 6, None, &d, None),
1895            Err(EdgeErrors::EmptyCounts { .. })
1896        ));
1897        assert!(matches!(
1898            equalize_lib_sizes(&EQ_COUNTS[..12], 3, 6, None, &d, None),
1899            Err(EdgeErrors::LengthMismatch { name: "counts", .. })
1900        ));
1901        assert!(matches!(
1902            equalize_lib_sizes(&EQ_COUNTS, 3, 6, Some(&[1, 1, 2]), &d, None),
1903            Err(EdgeErrors::LengthMismatch { name: "group", .. })
1904        ));
1905        assert!(matches!(
1906            equalize_lib_sizes(&EQ_COUNTS, 3, 6, None, &d, Some(&[1e6, 1e6])),
1907            Err(EdgeErrors::LengthMismatch {
1908                name: "lib_size",
1909                ..
1910            })
1911        ));
1912        assert!(matches!(
1913            equalize_lib_sizes(
1914                &EQ_COUNTS,
1915                3,
1916                6,
1917                None,
1918                &d,
1919                Some(&[1e6, 0.0, 1e6, 1e6, 1e6, 1e6])
1920            ),
1921            Err(EdgeErrors::InvalidArgument(_))
1922        ));
1923        assert!(matches!(
1924            equalize_lib_sizes(&EQ_COUNTS, 3, 6, None, &Recycled::scalar(-0.1), None),
1925            Err(EdgeErrors::InvalidDispersion(_))
1926        ));
1927        assert!(matches!(
1928            equalize_lib_sizes(
1929                &EQ_COUNTS,
1930                3,
1931                6,
1932                None,
1933                &Recycled::by_gene(vec![0.1, 0.2]),
1934                None
1935            ),
1936            Err(EdgeErrors::LengthMismatch {
1937                name: "by_gene",
1938                ..
1939            })
1940        ));
1941        assert!(matches!(
1942            equalize_lib_sizes(
1943                &EQ_COUNTS,
1944                3,
1945                6,
1946                None,
1947                &Recycled::by_sample(vec![0.1; 6]),
1948                None
1949            ),
1950            Err(EdgeErrors::InvalidArgument(_))
1951        ));
1952    }
1953
1954    ///////////////////////
1955    // Rejection regions //
1956    ///////////////////////
1957
1958    /// Six genes split two against four, straight into the kernels.
1959    /// ```r
1960    /// y1 <- matrix(c(10,12, 50,48, 2,0, 0,0, 300,290, 5,7), nrow=6, byrow=TRUE)
1961    /// y2 <- matrix(c(40,44,38,41, 49,51,50,52, 1,3,0,2, 7,9,8,6,
1962    ///                100,110,105,99, 5,6,7,5), nrow=6, byrow=TRUE)
1963    /// disp <- c(0.05,0.1,0.2,0.15,0.08,0.12)
1964    /// ```
1965    #[rustfmt::skip]
1966    const K_Y1: [f64; 12] = [
1967        10.0, 12.0, 50.0, 48.0, 2.0, 0.0, 0.0, 0.0, 300.0, 290.0, 5.0, 7.0,
1968    ];
1969    #[rustfmt::skip]
1970    const K_Y2: [f64; 24] = [
1971        40.0, 44.0, 38.0, 41.0,
1972        49.0, 51.0, 50.0, 52.0,
1973        1.0, 3.0, 0.0, 2.0,
1974        7.0, 9.0, 8.0, 6.0,
1975        100.0, 110.0, 105.0, 99.0,
1976        5.0, 6.0, 7.0, 5.0,
1977    ];
1978
1979    /// `cat(format(edgeR:::exactTestDoubleTail(y1, y2, dispersion=disp), digits=17), sep=", ")`
1980    #[test]
1981    fn test_double_tail_matches_edger_on_unequal_groups() {
1982        let want = [
1983            1.0877946590376498e-05,
1984            9.6474841698552705e-01,
1985            9.6034839393473215e-01,
1986            3.2619499340000606e-04,
1987            1.8490293273658116e-05,
1988            1.0000000000000000e+00,
1989        ];
1990        let got = exact_pvalues(
1991            &K_Y1,
1992            &K_Y2,
1993            6,
1994            2,
1995            4,
1996            &DISP,
1997            RejectionRegion::DoubleTail,
1998            BIG_COUNT_DEFAULT,
1999        )
2000        .unwrap();
2001        assert_close(&got, &want);
2002    }
2003
2004    /// `cat(format(edgeR:::exactTestByDeviance(y1, y2, dispersion=disp), digits=17), sep=", ")`
2005    #[test]
2006    fn test_deviance_matches_edger_on_unequal_groups() {
2007        let want = [
2008            8.1344278851021571e-06,
2009            9.2093279234031145e-01,
2010            7.5779954527582549e-01,
2011            1.8059270195564577e-04,
2012            2.0166440897726341e-05,
2013            1.0000000000000000e+00,
2014        ];
2015        let got = exact_pvalues(
2016            &K_Y1,
2017            &K_Y2,
2018            6,
2019            2,
2020            4,
2021            &DISP,
2022            RejectionRegion::Deviance,
2023            BIG_COUNT_DEFAULT,
2024        )
2025        .unwrap();
2026        assert_close(&got, &want);
2027    }
2028
2029    /// edgeR's `exactTestBySmallP` ends on `min(pvals, 1)` rather than `pmin`
2030    /// (see `UPSTREAM_DEVIATIONS.md` B3). The reference is the function called
2031    /// one gene at a time:
2032    /// `for (i in 1:6) edgeR:::exactTestBySmallP(y1[i,,drop=FALSE],`
2033    /// `y2[i,,drop=FALSE], dispersion=disp[i])`
2034    #[test]
2035    fn test_small_p_matches_edger_called_one_gene_at_a_time() {
2036        let want = [
2037            1.1812148275602017e-05,
2038            9.9999999999999922e-01,
2039            1.0000000000000000e+00,
2040            3.7544809480160240e-04,
2041            1.4354611746487754e-05,
2042            8.9101578715497332e-01,
2043        ];
2044        let got = exact_pvalues(
2045            &K_Y1,
2046            &K_Y2,
2047            6,
2048            2,
2049            4,
2050            &DISP,
2051            RejectionRegion::SmallP,
2052            BIG_COUNT_DEFAULT,
2053        )
2054        .unwrap();
2055        assert_close(&got, &want);
2056    }
2057
2058    /// A common dispersion rather than one per gene.
2059    /// `cat(format(edgeR:::exactTestDoubleTail(y1, y2, dispersion=0.1), digits=17), sep=", ")`
2060    /// and the same for `exactTestByDeviance`.
2061    #[test]
2062    fn test_kernels_match_edger_at_a_common_dispersion() {
2063        let common = [0.1_f64; 6];
2064        let want_dt = [
2065            0.00043066236852219725,
2066            0.96474841698552704639,
2067            0.94916961181158332472,
2068            0.00014095556701738610,
2069            0.00011337582143935078,
2070            1.00000000000000000000,
2071        ];
2072        let got = exact_pvalues(
2073            &K_Y1,
2074            &K_Y2,
2075            6,
2076            2,
2077            4,
2078            &common,
2079            RejectionRegion::DoubleTail,
2080            BIG_COUNT_DEFAULT,
2081        )
2082        .unwrap();
2083        assert_close(&got, &want_dt);
2084
2085        let want_dev = [
2086            0.00033859092769447587,
2087            0.92093279234031144576,
2088            0.74358178328280177816,
2089            0.00007467786708842068,
2090            0.00012974328406972557,
2091            0.99999999999999977796,
2092        ];
2093        let got = exact_pvalues(
2094            &K_Y1,
2095            &K_Y2,
2096            6,
2097            2,
2098            4,
2099            &common,
2100            RejectionRegion::Deviance,
2101            BIG_COUNT_DEFAULT,
2102        )
2103        .unwrap();
2104        assert_close(&got, &want_dev);
2105    }
2106
2107    /// With equal group sizes all three regions coincide, and edgeR routes the
2108    /// other two to the double-tail test.
2109    /// ```r
2110    /// z1 <- matrix(c(10,12,11, 2,0,5, 300,290,310), nrow=3, byrow=TRUE)
2111    /// z2 <- matrix(c(40,44,38, 1,3,0, 100,110,105), nrow=3, byrow=TRUE)
2112    /// cat(format(edgeR:::exactTestDoubleTail(z1, z2, dispersion=c(0.05,0.2,0.08)), digits=17))
2113    /// ```
2114    #[test]
2115    fn test_regions_coincide_when_the_groups_are_the_same_size() {
2116        #[rustfmt::skip]
2117        let z1 = [10.0, 12.0, 11.0, 2.0, 0.0, 5.0, 300.0, 290.0, 310.0];
2118        #[rustfmt::skip]
2119        let z2 = [40.0, 44.0, 38.0, 1.0, 3.0, 0.0, 100.0, 110.0, 105.0];
2120        let d = [0.05, 0.2, 0.08];
2121        let want = [
2122            8.2785897767219603e-07,
2123            6.1008651331231956e-01,
2124            1.8992725372229388e-05,
2125        ];
2126        for region in [
2127            RejectionRegion::DoubleTail,
2128            RejectionRegion::Deviance,
2129            RejectionRegion::SmallP,
2130        ] {
2131            let got = exact_pvalues(&z1, &z2, 3, 3, 3, &d, region, BIG_COUNT_DEFAULT).unwrap();
2132            assert_close(&got, &want);
2133        }
2134    }
2135
2136    /// The beta approximation, both sums well past `big_count`.
2137    /// ```r
2138    /// b1 <- matrix(c(1200,1100,1300, 2,3,4, 950,960,970), nrow=3, byrow=TRUE)
2139    /// b2 <- matrix(c(2400,2500,2300, 950,960,970, 950,960,970), nrow=3, byrow=TRUE)
2140    /// cat(format(edgeR:::exactTestBetaApprox(b1, b2, c(0.08,0.05,0.1)), digits=17))
2141    /// ```
2142    #[test]
2143    fn test_beta_approximation_matches_edger() {
2144        let want = [
2145            3.1654679012061791e-03,
2146            2.7141288810736955e-110,
2147            1.0000000000000000e+00,
2148        ];
2149        let got = [
2150            beta_approx_one(3600.0, 7200.0, 3, 3, 0.08).unwrap(),
2151            beta_approx_one(9.0, 2880.0, 3, 3, 0.05).unwrap(),
2152            beta_approx_one(2880.0, 2880.0, 3, 3, 0.1).unwrap(),
2153        ];
2154        assert_close(&got, &want);
2155    }
2156
2157    /// Above `big_count` the double-tail test uses the beta approximation. Gene 2
2158    /// of the pipeline fixture changes when the threshold drops to 100.
2159    #[test]
2160    fn test_big_count_threshold_switches_paths() {
2161        let y1 = [1200.0, 1100.0, 1300.0];
2162        let y2 = [2400.0, 2500.0, 2300.0];
2163        let mut buf = Vec::new();
2164        let exact = double_tail_one(&y1, &y2, 0.08, 1e12, &mut buf).unwrap();
2165        let approx = double_tail_one(&y1, &y2, 0.08, BIG_COUNT_DEFAULT, &mut buf).unwrap();
2166        assert_relative_eq!(approx, 3.1654679012061791e-03, max_relative = TOL);
2167        // Convolution and approximation agree to about three digits.
2168        assert_relative_eq!(exact, approx, max_relative = 1e-2);
2169        assert!(exact != approx);
2170    }
2171
2172    /// At zero dispersion every region routes through the exact binomial test.
2173    /// `cat(format(edgeR:::exactTestDoubleTail(y1, y2, dispersion=0), digits=17), sep=", ")`
2174    ///
2175    /// Gene 6 is the exception, and edgeR is wrong (see
2176    /// `UPSTREAM_DEVIATIONS.md` B4). The split is 12 against 23 at `p = 1/3`;
2177    /// a total of 35 makes outcomes 11 and 12 *exactly* equiprobable, so the
2178    /// p-value is 1. edgeR reports 0.86009048828607226, having dropped a tied
2179    /// term on a one-ulp difference in `dbinom`. R's `binom.test(12, 35, 1/3)`
2180    /// returns 1, as does this.
2181    #[test]
2182    fn test_zero_dispersion_uses_the_binomial_test() {
2183        let want = [
2184            2.3227510676278390e-11,
2185            8.5428783017505106e-01,
2186            1.0000000000000000e+00,
2187            6.6897430840626251e-06,
2188            8.1441784505498174e-61,
2189            1.0000000000000000e+00,
2190        ];
2191        let zero = [0.0_f64; 6];
2192        let got = exact_pvalues(
2193            &K_Y1,
2194            &K_Y2,
2195            6,
2196            2,
2197            4,
2198            &zero,
2199            RejectionRegion::DoubleTail,
2200            BIG_COUNT_DEFAULT,
2201        )
2202        .unwrap();
2203        assert_close(&got, &want);
2204    }
2205
2206    /// `cat(format(binomTest(c(0,3,10,50,300), c(0,5,10,60,290), p=0.5), digits=17))`
2207    /// and the same at `p = 1/3` and `p = 0.25`.
2208    #[test]
2209    fn test_binom_test_matches_edger() {
2210        let y1 = [0.0, 3.0, 10.0, 50.0, 300.0];
2211        let y2 = [0.0, 5.0, 10.0, 60.0, 290.0];
2212
2213        let want_half = [
2214            1.00000000000000000,
2215            0.72656250000000033,
2216            1.00000000000000000,
2217            0.39092745669950468,
2218            0.71102572604547820,
2219        ];
2220        for (i, w) in want_half.iter().enumerate() {
2221            assert_relative_eq!(
2222                binom_test_one(y1[i], y2[i], 0.5).unwrap(),
2223                w,
2224                max_relative = TOL
2225            );
2226        }
2227
2228        // Index 1 is a 3 against 5 split at p = 1/3: outcomes 2 and 3 are
2229        // exactly equiprobable. edgeR reports 0.72687090382563635 (dropped tied
2230        // term); `binom.test(3, 8, 1/3)` and this report 1. Every other entry
2231        // agrees with edgeR to the tolerance.
2232        let want_third = [
2233            1.0000000000000000e+00,
2234            1.0000000000000000e+00,
2235            1.5234223511142742e-01,
2236            8.4044576876146256e-03,
2237            2.2012099642833954e-18,
2238        ];
2239        for (i, w) in want_third.iter().enumerate() {
2240            assert_relative_eq!(
2241                binom_test_one(y1[i], y2[i], 1.0 / 3.0).unwrap(),
2242                w,
2243                max_relative = TOL
2244            );
2245        }
2246
2247        // `binomTest(c(2,7,40), c(6,3,60), p=0.25)`
2248        let want_quarter = [
2249            1.0000000000000000000,
2250            0.0035057067871093776,
2251            0.0010801099946617424,
2252        ];
2253        let a = [2.0, 7.0, 40.0];
2254        let b = [6.0, 3.0, 60.0];
2255        for (i, w) in want_quarter.iter().enumerate() {
2256            assert_relative_eq!(
2257                binom_test_one(a[i], b[i], 0.25).unwrap(),
2258                w,
2259                max_relative = TOL
2260            );
2261        }
2262    }
2263
2264    ////////////////
2265    // exact_test //
2266    ////////////////
2267
2268    /// `cat(format(exactTest(d, dispersion=disp)$table$logCPM, digits=17), sep=", ")`
2269    const A_LOG_CPM: [f64; 6] = [
2270        4.6828570480490503,
2271        5.6094058355452656,
2272        1.8369026364109835,
2273        2.4631896748603697,
2274        10.7199768972231873,
2275        2.8848509178259678,
2276    ];
2277
2278    /// The whole pipeline against `exactTest`, equal groups.
2279    /// ```r
2280    /// d <- DGEList(counts=y, group=c(1,1,1,2,2,2), lib.size=ls, norm.factors=nf)
2281    /// r <- exactTest(d, dispersion=disp)
2282    /// cat(format(r$table$logFC, digits=17), sep=", ")
2283    /// ```
2284    #[test]
2285    fn test_exact_test_matches_edger_on_equal_groups() {
2286        let want_fc = [
2287            1.76837964949810367,
2288            -0.13190914581416660,
2289            -0.89031602850510549,
2290            5.98706174338887909,
2291            0.86708062515865070,
2292            -0.12249104113230144,
2293        ];
2294        let want_p = [
2295            3.4349237412116053e-06,
2296            7.6299000470704081e-01,
2297            4.6743523769487572e-01,
2298            1.4972774802797630e-05,
2299            1.0337909917591625e-02,
2300            8.9951524706410912e-01,
2301        ];
2302
2303        let y = fixture(vec![1, 1, 1, 2, 2, 2]);
2304        let dispersion = Recycled::by_gene(DISP.to_vec());
2305        for region in [
2306            RejectionRegion::DoubleTail,
2307            RejectionRegion::Deviance,
2308            RejectionRegion::SmallP,
2309        ] {
2310            let params = ExactTestParams::new(region, BIG_COUNT_DEFAULT, PRIOR_COUNT_DEFAULT);
2311            let got = exact_test(&y, (1, 2), Some(&dispersion), Some(params)).unwrap();
2312            assert_close(&got.log_fc, &want_fc);
2313            assert_close(&got.log_cpm, &A_LOG_CPM);
2314            assert_close(&got.p_value, &want_p);
2315        }
2316    }
2317
2318    /// Unequal groups, where the three regions genuinely differ.
2319    /// `d <- DGEList(counts=y, group=c(1,1,2,2,2,2), lib.size=ls, norm.factors=nf)`
2320    #[test]
2321    fn test_exact_test_matches_edger_on_unequal_groups() {
2322        let want_fc = [
2323            1.617329610273079199,
2324            0.089998354795996716,
2325            1.144582075236848073,
2326            5.616562904007275314,
2327            0.900674912831392449,
2328            0.052897540463604717,
2329        ];
2330        let y = fixture(vec![1, 1, 2, 2, 2, 2]);
2331        let dispersion = Recycled::by_gene(DISP.to_vec());
2332
2333        // `exactTest(d, dispersion=disp, rejection.region="doubletail")`
2334        let want_dt = [
2335            0.00017528190903009128,
2336            0.87366769045812053829,
2337            0.45299967984928735110,
2338            0.00158610472572541451,
2339            0.01631959757549012249,
2340            1.00000000000000000000,
2341        ];
2342        let got = exact_test(&y, (1, 2), Some(&dispersion), None).unwrap();
2343        assert_close(&got.log_fc, &want_fc);
2344        assert_close(&got.log_cpm, &A_LOG_CPM);
2345        assert_close(&got.p_value, &want_dt);
2346
2347        // `rejection.region="deviance"`
2348        let want_dev = [
2349            0.00014592570324165402,
2350            0.84420783731055693000,
2351            0.32771177133536538717,
2352            0.00089863870338400599,
2353            0.01523232012423183027,
2354            1.00000000000000000000,
2355        ];
2356        let params = ExactTestParams::new(
2357            RejectionRegion::Deviance,
2358            BIG_COUNT_DEFAULT,
2359            PRIOR_COUNT_DEFAULT,
2360        );
2361        let got = exact_test(&y, (1, 2), Some(&dispersion), Some(params)).unwrap();
2362        assert_close(&got.p_value, &want_dev);
2363    }
2364
2365    /// `exactTestBySmallP` cannot be read off `exactTest` for more than one gene
2366    /// (its `min` bug), so this checks only that the region differs from the
2367    /// other two and stays a probability.
2368    #[test]
2369    fn test_exact_test_small_p_region_runs_on_unequal_groups() {
2370        let y = fixture(vec![1, 1, 2, 2, 2, 2]);
2371        let dispersion = Recycled::by_gene(DISP.to_vec());
2372        let params = ExactTestParams::new(
2373            RejectionRegion::SmallP,
2374            BIG_COUNT_DEFAULT,
2375            PRIOR_COUNT_DEFAULT,
2376        );
2377        let got = exact_test(&y, (1, 2), Some(&dispersion), Some(params)).unwrap();
2378        assert!(got.p_value.iter().all(|p| (0.0..=1.0).contains(p)));
2379        let double = exact_test(&y, (1, 2), Some(&dispersion), None).unwrap();
2380        assert!(got.p_value[0] != double.p_value[0]);
2381    }
2382
2383    /// Lowering `big_count` moves gene 2 onto the beta approximation, and
2384    /// nothing else.
2385    /// `cat(format(exactTest(d, dispersion=disp, big.count=100)$table$PValue, digits=17))`
2386    #[test]
2387    fn test_exact_test_honours_a_lowered_big_count() {
2388        let want = [
2389            3.4349237412116053e-06,
2390            7.6605971775209569e-01,
2391            4.6743523769487572e-01,
2392            1.4972774802797630e-05,
2393            1.0337909917591625e-02,
2394            8.9951524706410912e-01,
2395        ];
2396        let y = fixture(vec![1, 1, 1, 2, 2, 2]);
2397        let dispersion = Recycled::by_gene(DISP.to_vec());
2398        let params = ExactTestParams::new(RejectionRegion::DoubleTail, 100.0, PRIOR_COUNT_DEFAULT);
2399        let got = exact_test(&y, (1, 2), Some(&dispersion), Some(params)).unwrap();
2400        assert_close(&got.p_value, &want);
2401    }
2402
2403    /// The prior count only moves the fold change, most for gene 4 (zero in the
2404    /// first group).
2405    /// `cat(format(exactTest(d, dispersion=disp, prior.count=0.5)$table$logFC, digits=17))`
2406    #[test]
2407    fn test_exact_test_honours_the_prior_count() {
2408        let want = [
2409            1.73637993337830987,
2410            -0.13094130138685259,
2411            -0.74393133794193356,
2412            4.05433589435307962,
2413            0.86689211695718937,
2414            -0.11516957613998995,
2415        ];
2416        let y = fixture(vec![1, 1, 1, 2, 2, 2]);
2417        let dispersion = Recycled::by_gene(DISP.to_vec());
2418        let params = ExactTestParams::new(RejectionRegion::DoubleTail, BIG_COUNT_DEFAULT, 0.5);
2419        let got = exact_test(&y, (1, 2), Some(&dispersion), Some(params)).unwrap();
2420        assert_close(&got.log_fc, &want);
2421    }
2422
2423    /// Reversing the pair flips the fold change sign and leaves the p-value.
2424    #[test]
2425    fn test_exact_test_pair_order_flips_the_fold_change() {
2426        let y = fixture(vec![1, 1, 1, 2, 2, 2]);
2427        let dispersion = Recycled::by_gene(DISP.to_vec());
2428        let forward = exact_test(&y, (1, 2), Some(&dispersion), None).unwrap();
2429        let reverse = exact_test(&y, (2, 1), Some(&dispersion), None).unwrap();
2430        for (f, r) in forward.log_fc.iter().zip(reverse.log_fc.iter()) {
2431            assert_relative_eq!(*f, -r, max_relative = 1e-10);
2432        }
2433        assert_close(&forward.p_value, &reverse.p_value);
2434    }
2435
2436    /// A dispersion carried on the container is used when none is passed.
2437    #[test]
2438    fn test_exact_test_falls_back_to_the_container_dispersion() {
2439        let mut y = fixture(vec![1, 1, 1, 2, 2, 2]);
2440        y.tagwise_dispersion = Some(DISP.to_vec());
2441        let got = exact_test(&y, (1, 2), None, None).unwrap();
2442        let dispersion = Recycled::by_gene(DISP.to_vec());
2443        let want = exact_test(&y, (1, 2), Some(&dispersion), None).unwrap();
2444        assert_close(&got.p_value, &want.p_value);
2445    }
2446
2447    /// `f32` counts agree to single precision.
2448    #[test]
2449    fn test_exact_test_is_generic_over_the_count_type() {
2450        let counts32: Vec<f32> = COUNTS.iter().map(|v| *v as f32).collect();
2451        let mut y32 = DgeList::new(counts32, 6, 6, Some(vec![1, 1, 1, 2, 2, 2])).unwrap();
2452        y32.lib_size = LIB.to_vec();
2453        y32.norm_factors = NF.to_vec();
2454
2455        let dispersion = Recycled::by_gene(DISP.to_vec());
2456        let got = exact_test(&y32, (1, 2), Some(&dispersion), None).unwrap();
2457        let want = exact_test(
2458            &fixture(vec![1, 1, 1, 2, 2, 2]),
2459            (1, 2),
2460            Some(&dispersion),
2461            None,
2462        )
2463        .unwrap();
2464        for (g, w) in got.p_value.iter().zip(want.p_value.iter()) {
2465            assert_relative_eq!(g, w, epsilon = 1e-6, max_relative = 1e-5);
2466        }
2467    }
2468
2469    #[test]
2470    fn test_exact_test_rejects_bad_input() {
2471        let dispersion = Recycled::by_gene(DISP.to_vec());
2472
2473        // No group on the container.
2474        let mut ungrouped = fixture(vec![1, 1, 1, 2, 2, 2]);
2475        ungrouped.group = None;
2476        assert!(matches!(
2477            exact_test(&ungrouped, (1, 2), Some(&dispersion), None),
2478            Err(EdgeErrors::InvalidArgument(_))
2479        ));
2480
2481        let y = fixture(vec![1, 1, 1, 2, 2, 2]);
2482        // A pair naming the same group twice.
2483        assert!(matches!(
2484            exact_test(&y, (1, 1), Some(&dispersion), None),
2485            Err(EdgeErrors::InvalidArgument(_))
2486        ));
2487        // A group with no samples.
2488        assert!(matches!(
2489            exact_test(&y, (1, 7), Some(&dispersion), None),
2490            Err(EdgeErrors::InvalidArgument(_))
2491        ));
2492        // No dispersion anywhere.
2493        assert!(matches!(
2494            exact_test(&y, (1, 2), None, None),
2495            Err(EdgeErrors::InvalidDispersion(_))
2496        ));
2497        // A negative dispersion.
2498        assert!(matches!(
2499            exact_test(&y, (1, 2), Some(&Recycled::scalar(-0.1)), None),
2500            Err(EdgeErrors::InvalidDispersion(_))
2501        ));
2502        // A dispersion of the wrong length.
2503        assert!(matches!(
2504            exact_test(&y, (1, 2), Some(&Recycled::by_gene(vec![0.1, 0.2])), None),
2505            Err(EdgeErrors::LengthMismatch { .. })
2506        ));
2507        // A negative prior count.
2508        let params = ExactTestParams::new(RejectionRegion::DoubleTail, BIG_COUNT_DEFAULT, -1.0);
2509        assert!(matches!(
2510            exact_test(&y, (1, 2), Some(&dispersion), Some(params)),
2511            Err(EdgeErrors::InvalidArgument(_))
2512        ));
2513        // A zero library size.
2514        let mut zero_lib = fixture(vec![1, 1, 1, 2, 2, 2]);
2515        zero_lib.lib_size[0] = 0.0;
2516        assert!(matches!(
2517            exact_test(&zero_lib, (1, 2), Some(&dispersion), None),
2518            Err(EdgeErrors::InvalidArgument(_))
2519        ));
2520        // A pre-set log-CPM of the wrong length.
2521        let mut bad_alc = fixture(vec![1, 1, 1, 2, 2, 2]);
2522        bad_alc.ave_log_cpm = Some(vec![1.0, 2.0]);
2523        assert!(matches!(
2524            exact_test(&bad_alc, (1, 2), Some(&dispersion), None),
2525            Err(EdgeErrors::LengthMismatch {
2526                name: "ave_log_cpm",
2527                ..
2528            })
2529        ));
2530    }
2531
2532    #[test]
2533    fn test_log_sum_exp_handles_an_empty_and_an_all_infinite_input() {
2534        assert_eq!(log_sum_exp(&[]), f64::NEG_INFINITY);
2535        assert_eq!(
2536            log_sum_exp(&[f64::NEG_INFINITY, f64::NEG_INFINITY]),
2537            f64::NEG_INFINITY
2538        );
2539        assert_relative_eq!(log_sum_exp(&[0.0, 0.0]), LN_2, max_relative = 1e-15);
2540    }
2541}