Skip to main content

salmon_model/
gcbias.rs

1//! Fragment-GC bias model (`GCFragModel`).
2//!
3//! # The effect being modelled
4//!
5//! PCR amplification, which every library prep relies on, does not copy all
6//! fragments equally well. Fragments that are very GC-rich or very GC-poor
7//! amplify less efficiently than middling ones, so they end up under-represented
8//! in the sequencer's output. A transcript with unusual GC content therefore
9//! looks less abundant than it is — an artefact of chemistry, not biology.
10//!
11//! A port of salmon's `GCFragModel` (`include/.../model/GCFragModel.hpp`): a
12//! `condBins × numGCBins` table of fragment counts, where a fragment is keyed by
13//! its **GC fraction** (0–100, → `numGCBins` bins) and a **conditioning context**
14//! (a coarse sequence/context fraction, → `condBins` bins).
15//!
16//! **Why condition on the context.** GC content immediately *around* a fragment's
17//! ends also affects priming and ligation. Splitting the table by that context
18//! keeps the GC effect from absorbing what is really a sequence-composition
19//! effect at the ends.
20//!
21//! An *observed* model (from mapped fragments) and an *expected* model (from the
22//! transcriptome) are each normalized per conditioning row into a distribution,
23//! then divided — [`gc_ratio`] — to give the per-`(context, GC)` bias factor used
24//! to weight fragments in the bias-corrected effective length. The expected model
25//! answers "what GC contents would we see if there were no GC bias at all, given
26//! these transcripts and abundances?", so the ratio isolates the bias itself.
27//!
28//! salmon accumulates the observed model in log space and the expected in linear
29//! space, but both are normalized to linear distributions before the ratio is
30//! taken, so the normalized result is independent of the accumulation space.
31//! We therefore accumulate in **linear** space (summing weights) throughout —
32//! numerically equivalent after [`GcFragModel::normalize`], and simpler.
33
34/// salmon's default number of conditioning (context) bins (`numConditionalGCBins`).
35pub const DEFAULT_COND_BINS: usize = 3;
36/// salmon's default number of fragment-GC bins for accumulation
37/// (`salmon::defaults::numFragGCBins`).
38pub const DEFAULT_GC_BINS: usize = 25;
39/// salmon's `ratio()` clamp (`gcBias = gcCounts.ratio(transcriptGCDist, 1000.0)`).
40///
41/// A GC bin observed a handful of times against a near-zero expectation would
42/// otherwise produce an enormous factor from pure noise; the clamp bounds how
43/// far any single cell can move a transcript's effective length.
44pub const GC_MAX_RATIO: f64 = 1000.0;
45/// salmon's per-row normalization prior.
46///
47/// A pseudocount added to every bin before normalizing, so an unobserved GC bin
48/// gets a small probability instead of exactly zero (which would make its ratio a
49/// division by zero).
50const NORM_PRIOR: f64 = 0.1;
51
52/// Bin a 0–100 fraction into `n` bins (salmon's `GCDesc::fragBin(n)`/`contextBin(n)`:
53/// `min(n-1, floor(frac / (100/n)))`). With `n == 101` this is the identity.
54///
55/// Binning trades resolution for statistical strength: 25 bins each see plenty of
56/// fragments, whereas 101 would be noisy on a small sample.
57#[inline]
58pub fn bin_frac(frac: i32, n: usize) -> usize {
59    if n == 101 {
60        return (frac.clamp(0, 100)) as usize;
61    }
62    let w = 100.0 / n as f64;
63    let b = (frac as f64 / w) as i32;
64    b.clamp(0, n as i32 - 1) as usize
65}
66
67/// `(condBins × numGCBins)` table of fragment-GC counts (then a normalized
68/// distribution / ratio after [`normalize`](Self::normalize) / [`gc_ratio`]).
69#[derive(Debug, Clone)]
70pub struct GcFragModel {
71    cond_bins: usize,
72    gc_bins: usize,
73    /// row-major `counts[ctx * gc_bins + gc]`. Until [`normalize`], this is the
74    /// f64 *view* materialized from `counts_fp`; the live accumulator during
75    /// collection is the integer `counts_fp` (for thread-count-independent sums).
76    counts: Vec<f64>,
77    /// Fixed-point integer accumulator (`mass * BIAS_WEIGHT_SCALE`, truncated);
78    /// summed order-independently across worker threads, materialized into
79    /// `counts` once at [`normalize`]. See [`crate::BIAS_WEIGHT_SCALE`].
80    counts_fp: Vec<u64>,
81    normalized: bool,
82    /// Precomputed `[0..=100] -> bin` maps for the context and GC fractions
83    /// (both always lie in `[0, 100]`). [`bin_frac`] is ~44% of the eff-length
84    /// convolution self-time (two `100.0/n` + `frac/w` divides per [`get`]/[`inc`]
85    /// call, called per fragment); these LUTs replace both divides with a byte
86    /// load. Each entry is exactly `bin_frac(frac, n)`, so the result is
87    /// bit-identical to the divide path (verified in tests).
88    ctx_lut: Vec<u8>,
89    gc_lut: Vec<u8>,
90}
91
92impl GcFragModel {
93    pub fn new(cond_bins: usize, gc_bins: usize) -> Self {
94        let ctx_lut = (0..=100).map(|f| bin_frac(f, cond_bins) as u8).collect();
95        let gc_lut = (0..=100).map(|f| bin_frac(f, gc_bins) as u8).collect();
96        Self {
97            cond_bins,
98            gc_bins,
99            counts: vec![0.0; cond_bins * gc_bins],
100            counts_fp: vec![0u64; cond_bins * gc_bins],
101            normalized: false,
102            ctx_lut,
103            gc_lut,
104        }
105    }
106
107    /// salmon's default 3 × 101 model.
108    pub fn default_model() -> Self {
109        Self::new(DEFAULT_COND_BINS, DEFAULT_GC_BINS)
110    }
111
112    #[inline]
113    fn idx(&self, ctx_frac: i32, gc_frac: i32) -> usize {
114        // Clamp-then-LUT is bit-identical to `bin_frac` for every i32 input:
115        // for frac in [0,100] the LUT entry *is* `bin_frac(frac, n)`, and a frac
116        // outside [0,100] clamps to the same boundary bin `bin_frac` would
117        // produce (0 or n-1). Inputs are always lrint(100·ratio) in [0,100].
118        let ctx = if self.cond_bins > 1 {
119            self.ctx_lut[ctx_frac.clamp(0, 100) as usize] as usize
120        } else {
121            0
122        };
123        let gc = self.gc_lut[gc_frac.clamp(0, 100) as usize] as usize;
124        ctx * self.gc_bins + gc
125    }
126
127    /// The flattened `cond_bins × gc_bins` table (`counts[ctx * gc_bins + gc]`,
128    /// row-major), for dumping to the aux bias files.
129    pub fn dump(&self) -> &[f64] {
130        &self.counts
131    }
132
133    /// Accumulate `weight` for a fragment with the given GC and context fractions.
134    pub fn inc(&mut self, gc_frac: i32, ctx_frac: i32, weight: f64) {
135        debug_assert!(!self.normalized, "cannot inc a normalized model");
136        let i = self.idx(ctx_frac, gc_frac);
137        self.counts_fp[i] += crate::bias_mass_to_fp(weight);
138    }
139
140    /// Value at a `(context, GC)` cell (a normalized density after `normalize`,
141    /// or a clamped ratio for a model produced by [`gc_ratio`]).
142    #[inline]
143    pub fn get(&self, gc_frac: i32, ctx_frac: i32) -> f64 {
144        self.counts[self.idx(ctx_frac, gc_frac)]
145    }
146
147    /// Merge another (compatible) model's counts. Both must be pre-normalization.
148    pub fn combine_counts(&mut self, other: &GcFragModel) {
149        debug_assert!(!self.normalized && !other.normalized);
150        // Integer sum — associative, so the merged model is independent of how
151        // fragments were partitioned across worker threads.
152        for (a, b) in self.counts_fp.iter_mut().zip(&other.counts_fp) {
153            *a += *b;
154        }
155    }
156
157    /// Normalize each conditioning row into a distribution over GC bins, with a
158    /// pseudocount `prior` (salmon's default 0.1). Idempotent.
159    ///
160    /// Per *row*, not over the whole table: the ratio compares like with like
161    /// within one context, so each context's GC distribution must sum to 1 on its
162    /// own.
163    pub fn normalize(&mut self) {
164        if self.normalized {
165            return;
166        }
167        // Materialize the integer accumulator into the f64 `counts` the rest of
168        // the model (rows, `get`, `gc_ratio`, dumps) reads.
169        for (c, &fp) in self.counts.iter_mut().zip(&self.counts_fp) {
170            *c = fp as f64 / crate::BIAS_WEIGHT_SCALE;
171        }
172        for r in 0..self.cond_bins {
173            let base = r * self.gc_bins;
174            let row = &mut self.counts[base..base + self.gc_bins];
175            let row_mass: f64 = row.iter().map(|c| NORM_PRIOR + *c).sum();
176            if row_mass > 0.0 {
177                let norm = 1.0 / row_mass;
178                for c in row.iter_mut() {
179                    *c = (NORM_PRIOR + *c) * norm;
180                }
181            }
182        }
183        self.normalized = true;
184    }
185}
186
187/// Per-cell bias ratio `observed / expected`, clamped to `[1/max_ratio, max_ratio]`
188/// (salmon's `GCFragModel::ratio`). Both models are normalized first.
189///
190/// A value above 1 means fragments of that GC/context were seen *more* often than
191/// chance predicts, so such fragments are easy to sequence; below 1 means they are
192/// suppressed. The effective-length convolution multiplies each candidate fragment
193/// by its factor, so a transcript made of suppressed fragments gets a smaller
194/// effective length and therefore a higher inferred abundance.
195pub fn gc_ratio(
196    observed: &mut GcFragModel,
197    expected: &mut GcFragModel,
198    max_ratio: f64,
199) -> GcFragModel {
200    observed.normalize();
201    expected.normalize();
202    let min_ratio = 1.0 / max_ratio;
203    let mut out = GcFragModel::new(observed.cond_bins, observed.gc_bins);
204    for i in 0..out.counts.len() {
205        let e = expected.counts[i];
206        let rat = if e != 0.0 {
207            observed.counts[i] / e
208        } else {
209            max_ratio
210        };
211        out.counts[i] = rat.clamp(min_ratio, max_ratio);
212    }
213    out.normalized = true; // a ratio model is used directly, not re-normalized
214    out
215}
216
217// ===========================================================================
218// Fragment-GC content (salmon's `Transcript::GCCount_` / `gcFrac` / `gcDesc`)
219// ===========================================================================
220
221use crate::seqbias::{
222    conditional_cdf, log_bias, revcomp_bytes, SBModel, CONTEXT_LEFT, CONTEXT_LENGTH, MIN_ALPHA,
223    MIN_CDF_MASS,
224};
225
226/// salmon's fragment-length sampling stride for the GC convolution
227/// (`pdfSampFactor` = `biasSpeedSamp`, default 5).
228pub const GC_SAMP_STRIDE: usize = 5;
229
230/// `gcDesc` context-window geometry (salmon `Transcript::gcDesc`):
231/// `outsideContext = 3`, `insideContext = 2`.
232const OUTSIDE_5P: i32 = 4; // outsideContext + 1
233const OUTSIDE_3P: i32 = 3; // outsideContext
234const INSIDE_5P: i32 = 1; // insideContext - 1
235const INSIDE_3P: i32 = 2; // insideContext
236
237/// Round to nearest (ties to even), matching C++ `std::lrint` under the default
238/// rounding mode.
239///
240/// On x86-64 this is a single `cvtsd2si` instruction (which rounds per the
241/// current — default round-to-nearest-even — MXCSR mode), identical to salmon's
242/// `std::lrint`. The portable `f64::round_ties_even() as i32` lowers to a libm
243/// `roundeven` *function call* on this baseline build; since this is called
244/// ~2× per `gc_desc` over tens of billions of fragment evaluations under
245/// `--gcBias`, that call was the dominant per-iteration cost (≈4× the GC-bias
246/// runtime vs C++). Other architectures keep the portable path.
247#[inline]
248fn lrint(x: f64) -> i32 {
249    #[cfg(target_arch = "x86_64")]
250    {
251        // SAFETY: SSE2 is part of the x86-64 baseline, so `_mm_set_sd` /
252        // `_mm_cvtsd_si32` are always available. `cvtsd2si` matches `std::lrint`
253        // (round-to-nearest-even); our arguments are small (0–100), no overflow.
254        #[allow(unsafe_code)]
255        unsafe {
256            core::arch::x86_64::_mm_cvtsd_si32(core::arch::x86_64::_mm_set_sd(x))
257        }
258    }
259    #[cfg(not(target_arch = "x86_64"))]
260    {
261        x.round_ties_even() as i32
262    }
263}
264
265/// Cumulative G+C counts (salmon's `Transcript::GCCount_`): `prefix[p]` is the
266/// number of `G`/`C` bases in `seq[0..=p]`.
267///
268/// A *prefix sum*: with it, the GC count of any interval is one subtraction
269/// (`prefix[b] - prefix[a-1]`) instead of a scan. The convolution asks that
270/// question for every (start, length) pair, so the difference is decisive.
271pub fn gc_prefix(seq: &[u8]) -> Vec<u32> {
272    let mut prefix = Vec::with_capacity(seq.len());
273    let mut acc = 0u32;
274    for &b in seq {
275        if matches!(b, b'G' | b'g' | b'C' | b'c') {
276            acc += 1;
277        }
278        prefix.push(acc);
279    }
280    prefix
281}
282
283/// GC count in the closed interval `[a, b]` from a cumulative-count prefix.
284#[inline]
285fn gc_in(prefix: &[u32], a: i32, b: i32) -> i64 {
286    let lo = if a > 0 {
287        prefix[(a - 1) as usize] as i64
288    } else {
289        0
290    };
291    prefix[b as usize] as i64 - lo
292}
293
294/// Fragment GC fraction (0–100) over the closed interval `[s, e]`
295/// (salmon's `Transcript::gcFrac`): `round(100·GC[s,e] / (e−s+1))`.
296#[inline]
297pub fn gc_frac(prefix: &[u32], s: i32, e: i32) -> i32 {
298    lrint(100.0 * gc_in(prefix, s, e) as f64 / (e - s + 1) as f64)
299}
300
301/// A rank-enabled bitvector over the *concatenated* reference sequence marking
302/// G/C positions (salmon's `--reduceGCMemory` representation). Replaces the
303/// per-transcript dense cumulative-GC arrays (`Vec<Vec<u32>>`, 4 bytes/base) with
304/// one bitvector (~1 bit/base + 25% rank overhead) — GC in any interval is a
305/// difference of two O(1) ranks. Build once over the whole transcriptome.
306///
307/// A *rank* query answers "how many bits are set before position `i`?" in
308/// constant time, using a small precomputed table of block counts. That is
309/// exactly a prefix sum, at roughly 1/25th of the memory: on a human
310/// transcriptome plus genome decoy, ~300 MB becomes ~12 MB.
311pub struct GcRank {
312    rank: sux::rank_sel::Rank9,
313}
314
315impl GcRank {
316    /// Build the rank bitvector from the concatenated reference bytes
317    /// (`seq[i]` is G/C ⇒ bit `i` set).
318    pub fn new(concat: &[u8]) -> GcRank {
319        use sux::traits::bit_vec_ops::BitVecOpsMut;
320        let mut bv = sux::bits::BitVec::new(concat.len());
321        for (i, &b) in concat.iter().enumerate() {
322            if matches!(b, b'G' | b'g' | b'C' | b'c') {
323                bv.set(i, true);
324            }
325        }
326        GcRank {
327            rank: sux::rank_sel::Rank9::new(bv),
328        }
329    }
330
331    /// A cumulative-GC view of the transcript occupying `concat[off..off+len]`.
332    #[inline]
333    pub fn view(&self, off: usize, len: usize) -> GcView<'_> {
334        use sux::traits::Rank;
335        GcView::Rank {
336            rank: &self.rank,
337            off,
338            base: self.rank.rank(off),
339            len,
340        }
341    }
342}
343
344/// A per-transcript cumulative-GC accessor: either the dense prefix array
345/// (default) or a window of the shared [`GcRank`] (`--reduceGCMemory`). Both
346/// return the *same* cumulative counts, so results are identical; `cum(p)` is
347/// the number of G/C bases in transcript-local `[0, p]`.
348///
349/// An enum rather than a trait object: the choice is made once per run, and this
350/// keeps the call a predictable branch instead of an indirect jump on a path
351/// taken billions of times.
352#[derive(Clone, Copy)]
353pub enum GcView<'a> {
354    Dense(&'a [u32]),
355    Rank {
356        rank: &'a sux::rank_sel::Rank9,
357        off: usize,
358        /// `rank(off)` — precomputed so `cum` is a single rank query.
359        base: usize,
360        len: usize,
361    },
362}
363
364impl GcView<'_> {
365    /// Cumulative G/C count in transcript-local `[0, p]` (inclusive).
366    #[inline]
367    pub fn cum(&self, p: i32) -> i64 {
368        match self {
369            GcView::Dense(prefix) => prefix[p as usize] as i64,
370            GcView::Rank {
371                rank, off, base, ..
372            } => {
373                use sux::traits::Rank;
374                (rank.rank(off + p as usize + 1) - base) as i64
375            }
376        }
377    }
378
379    /// Transcript length in bases.
380    #[inline]
381    pub fn ref_len(&self) -> usize {
382        match self {
383            GcView::Dense(prefix) => prefix.len(),
384            GcView::Rank { len, .. } => *len,
385        }
386    }
387}
388
389/// A whole-transcriptome source of per-transcript [`GcView`]s: either dense
390/// per-transcript prefixes (default) or one shared [`GcRank`] window per
391/// transcript (`--reduceGCMemory`). Lets callers stay representation-agnostic.
392#[derive(Clone, Copy)]
393pub enum GcStore<'a> {
394    Dense(&'a [Vec<u32>]),
395    Rank {
396        rank: &'a GcRank,
397        offsets: &'a [u64],
398    },
399}
400
401impl<'a> GcStore<'a> {
402    /// The cumulative-GC view for transcript `tid`. The view borrows the
403    /// underlying data (lifetime `'a`), not `self`, so it outlives a temporary
404    /// `GcStore`.
405    #[inline]
406    pub fn view(&self, tid: usize) -> GcView<'a> {
407        match *self {
408            GcStore::Dense(prefixes) => GcView::Dense(&prefixes[tid]),
409            GcStore::Rank { rank, offsets } => {
410                let off = offsets[tid] as usize;
411                let len = (offsets[tid + 1] - offsets[tid]) as usize;
412                rank.view(off, len)
413            }
414        }
415    }
416}
417
418/// Fragment GC descriptor `(fragFrac, contextFrac)` for the closed fragment
419/// `[s, e]` (salmon's `Transcript::gcDesc`). The context fraction is the GC
420/// content over a 5-base 5' window `[s−3, s+1]` and a 5-base 3' window
421/// `[e−1, e+3]` (edge-clamped). Returns `None` when the context window is empty
422/// (matching salmon's `valid = false`).
423///
424/// The two returned numbers are exactly the table's two axes: how GC-rich the
425/// fragment is overall, and how GC-rich the short windows straddling its two ends
426/// are. The windows deliberately extend a few bases *outside* the fragment,
427/// because priming and ligation see the sequence just beyond the cut as well.
428#[inline]
429pub fn gc_desc(v: &GcView, s: i32, e: i32) -> Option<(i32, i32)> {
430    let last = v.ref_len() as i32 - 1;
431    let cs = if s > 0 { v.cum(s - 1) } else { 0 };
432    let ce = v.cum(e);
433
434    let fs = s - OUTSIDE_5P;
435    let fe = s + INSIDE_5P;
436    let ts = e - INSIDE_3P;
437    let te = e + OUTSIDE_3P;
438
439    let fp_left = fs >= 0;
440    let fp_right = fe <= last;
441    let tp_left = ts >= 0;
442    let tp_right = te <= last;
443
444    let fps = if fp_left { v.cum(fs) } else { 0 };
445    let fpe = if fp_right { v.cum(fe) } else { ce };
446    let tps = if tp_left { v.cum(ts) } else { 0 };
447    let tpe = if tp_right { v.cum(te) } else { ce };
448
449    let fs_c = fs.max(0);
450    let fe_c = fe.min(last);
451    let ts_c = ts.max(0);
452    let te_c = te.min(last);
453    let fp_context_size = if !fp_left { fe_c + 1 } else { fe_c - fs_c };
454    let tp_context_size = if !tp_left { te_c + 1 } else { te_c - ts_c };
455    let context_size = (fp_context_size + tp_context_size) as f64;
456    if context_size == 0.0 {
457        return None;
458    }
459
460    let frag_frac = lrint(100.0 * (ce - cs) as f64 / (e - s + 1) as f64);
461    let context_frac = lrint(100.0 * ((fpe - fps) + (tpe - tps)) as f64 / context_size);
462    Some((frag_frac, context_frac))
463}
464
465/// Per-position context-GC cache for a single transcript — salmon's
466/// `populateContextCounts`. [`gc_desc`] recomputes the 5'/3' context window
467/// geometry (four conditional prefix loads, four edge branches, a division)
468/// for *every* `(fragStart, fragEnd)` pair in the effective-length convolution;
469/// since the 5' term depends only on `fragStart` and the 3' term only on
470/// `fragEnd`, we hoist both to O(ref_len) per-position arrays and reduce the
471/// inner loop to two array loads + one division + one `lrint` — matching the
472/// C++ hot path. [`GcContext::desc`] is bit-identical to [`gc_desc`] for every
473/// fragment the convolution actually visits (`fragStart + INSIDE_5P <= last`,
474/// i.e. `fp_right` always holds — guaranteed since `fragStart <= ref_len-2`).
475pub struct GcContext {
476    cum: Vec<u32>,      // cumulative G/C count in [0, p], indexed by position
477    fp_count: Vec<i64>, // 5' window GC count, indexed by fragStart
478    fp_wlen: Vec<i32>,  // 5' window length, indexed by fragStart
479    tp_count: Vec<i64>, // 3' window GC count, indexed by fragEnd
480    tp_wlen: Vec<i32>,  // 3' window length, indexed by fragEnd
481}
482
483impl GcContext {
484    /// Precompute the per-position 5'/3' context arrays (and a transient copy of
485    /// the cumulative-GC counts) from any [`GcView`]. Each entry replicates
486    /// exactly the corresponding sub-expression of [`gc_desc`] (same
487    /// edge-clamping, same `fp_right`/`tp_right` handling). Materializing `cum`
488    /// here keeps the hot inner loop ([`GcContext::desc`]) free of any rank
489    /// queries or dense lookups beyond cached arrays.
490    pub fn build(v: &GcView) -> GcContext {
491        let n = v.ref_len();
492        let last = n as i32 - 1;
493        let cum: Vec<u32> = (0..n).map(|p| v.cum(p as i32) as u32).collect();
494        let at = |i: i32| cum[i as usize] as i64;
495        let mut fp_count = vec![0i64; n];
496        let mut fp_wlen = vec![0i32; n];
497        let mut tp_count = vec![0i64; n];
498        let mut tp_wlen = vec![0i32; n];
499        for p in 0..n {
500            let pos = p as i32;
501            // 3' window for a fragment *ending* at `pos`.
502            let ts = pos - INSIDE_3P;
503            let te = pos + OUTSIDE_3P;
504            let tp_left = ts >= 0;
505            let tp_right = te <= last;
506            let tps = if tp_left { at(ts) } else { 0 };
507            // `tpe = ce = cum[e]` when the 3' window runs off the end.
508            let tpe = if tp_right { at(te) } else { at(pos) };
509            let ts_c = ts.max(0);
510            let te_c = te.min(last);
511            tp_count[p] = tpe - tps;
512            tp_wlen[p] = if !tp_left { te_c + 1 } else { te_c - ts_c };
513
514            // 5' window for a fragment *starting* at `pos`. Within the
515            // convolution `fp_right` always holds; the `cum[p]` fallback is a
516            // placeholder for the never-visited `pos == last` slot.
517            let fs = pos - OUTSIDE_5P;
518            let fe = pos + INSIDE_5P;
519            let fp_left = fs >= 0;
520            let fp_right = fe <= last;
521            let fps = if fp_left { at(fs) } else { 0 };
522            let fpe = if fp_right { at(fe) } else { at(pos) };
523            let fs_c = fs.max(0);
524            let fe_c = fe.min(last);
525            fp_count[p] = fpe - fps;
526            fp_wlen[p] = if !fp_left { fe_c + 1 } else { fe_c - fs_c };
527        }
528        GcContext {
529            cum,
530            fp_count,
531            fp_wlen,
532            tp_count,
533            tp_wlen,
534        }
535    }
536
537    /// GC descriptor `(fragFrac, contextFrac)` for the closed fragment `[s, e]`,
538    /// bit-identical to [`gc_desc`] but using the cached context arrays. `s` and
539    /// `e` must lie in `0..ref_len` with `s + INSIDE_5P <= ref_len - 1`.
540    #[inline]
541    pub fn desc(&self, s: i32, e: i32) -> Option<(i32, i32)> {
542        let context_size = (self.fp_wlen[s as usize] + self.tp_wlen[e as usize]) as f64;
543        if context_size == 0.0 {
544            return None;
545        }
546        let cs = if s > 0 {
547            self.cum[(s - 1) as usize] as i64
548        } else {
549            0
550        };
551        let ce = self.cum[e as usize] as i64;
552        let count = (self.fp_count[s as usize] + self.tp_count[e as usize]) as f64;
553        let frag_frac = lrint(100.0 * (ce - cs) as f64 / (e - s + 1) as f64);
554        let context_frac = lrint(100.0 * count / context_size);
555        Some((frag_frac, context_frac))
556    }
557}
558
559/// Build the *expected* fragment-GC model (salmon's `transcriptGCDist`): slide
560/// every fragment `[fragStart, fragStart+fl−1]` over each expressed transcript,
561/// weighting each by `(alpha/effLen)·(conditionalCDF(fl) − prevMass)` — the same
562/// abundance-density × conditional-FLD weighting used for the expected
563/// sequence-bias model. `k` is the leading offset salmon excludes from the
564/// fragment-start loop (`9` with `--seqBias`, `1` otherwise); `stride` subsamples
565/// fragment lengths ([`GC_SAMP_STRIDE`]).
566///
567/// In words: enumerate every fragment that *could* have been sequenced, weight it
568/// by how abundant its transcript is and how likely its length is, and tally its
569/// GC content. The result is the GC distribution an unbiased protocol would have
570/// produced. Weighting by abundance matters because a highly expressed transcript
571/// dominates the observed distribution and must dominate the expected one too.
572///
573/// `stride` is a pure speed knob: the fragment-length distribution is smooth, so
574/// sampling every fifth length changes the model negligibly while cutting the
575/// work fivefold.
576#[allow(clippy::too_many_arguments)]
577pub fn build_expected_gc<'a, FS, FP>(
578    num_targets: usize,
579    seq_of: FS,
580    view_of: FP,
581    alphas: &[f64],
582    eff_lens: &[f64],
583    cdf: &[f64],
584    fld_low: usize,
585    fld_high: usize,
586    cond_bins: usize,
587    gc_bins: usize,
588    k: usize,
589    stride: usize,
590) -> GcFragModel
591where
592    FS: Fn(usize) -> &'a [u8] + Sync,
593    FP: Fn(usize) -> GcView<'a> + Sync,
594{
595    let stride = stride.max(1) as i32;
596    // The expected-GC distribution is a sum of independent per-transcript
597    // contributions, each an O(refLen · fldRange/stride) double loop — billions
598    // of `gc_desc` calls in total. salmon parallelizes this over transcripts;
599    // do the same with rayon (per-thread `GcFragModel` partials, reduced via
600    // `combine_counts`). `seq_of`/`prefix_of` must be `Sync` to share across
601    // threads (they are: closures over `&[Vec<_>]`).
602    use rayon::prelude::*;
603    let per_tid = |tid: usize| -> Option<GcFragModel> {
604        if alphas[tid] < MIN_ALPHA || eff_lens[tid] <= 0.0 {
605            return None;
606        }
607        let seq = seq_of(tid);
608        let ref_len = seq.len();
609        if ref_len <= k {
610            return None;
611        }
612        let cdf_max_arg = (cdf.len() - 1).min(ref_len);
613        let cdf_max_val = cdf[cdf_max_arg];
614        if cdf_max_val < MIN_CDF_MASS {
615            return None;
616        }
617        let view = view_of(tid);
618        let ctx = GcContext::build(&view);
619        let weight = alphas[tid] / eff_lens[tid];
620        let cond = |x: i32| conditional_cdf(cdf, cdf_max_arg, cdf_max_val, x);
621        let sp = if fld_low > 0 { fld_low as i32 - 1 } else { 0 };
622        let mut model = GcFragModel::new(cond_bins, gc_bins);
623        for frag_start in 0..(ref_len - k) {
624            let mut prev = cond(sp);
625            let mut fl = fld_low as i32;
626            while fl <= fld_high as i32 {
627                let frag_end = frag_start as i32 + fl - 1;
628                if (frag_end as usize) < ref_len {
629                    if let Some((ff, cf)) = ctx.desc(frag_start as i32, frag_end) {
630                        model.inc(ff, cf, weight * (cond(fl) - prev));
631                    }
632                    prev = cond(fl);
633                } else {
634                    break;
635                }
636                fl += stride;
637            }
638        }
639        Some(model)
640    };
641    (0..num_targets)
642        .into_par_iter()
643        .fold(
644            || GcFragModel::new(cond_bins, gc_bins),
645            |mut acc, tid| {
646                if let Some(m) = per_tid(tid) {
647                    acc.combine_counts(&m);
648                }
649                acc
650            },
651        )
652        .reduce(
653            || GcFragModel::new(cond_bins, gc_bins),
654            |mut a, b| {
655                a.combine_counts(&b);
656                a
657            },
658        )
659}
660
661/// Bias-corrected effective length including fragment-GC bias (and, when
662/// `seq_models` is provided, sequence-specific bias too). Mirrors salmon's
663/// combined `updateEffectiveLengths` convolution: per-position 5'/3' sequence
664/// factors are multiplied by the per-fragment `gcBias.get({fragFrac, contextFrac})`
665/// ratio, convolved with the conditional FLD, and floored at the lower barrier.
666///
667/// `gc_bias` is the normalized observed/expected ratio model ([`gc_ratio`]).
668/// `seq_models` is `(obs_fw, exp_fw, obs_rc, exp_rc)` when `--seqBias` is also on.
669///
670/// This is the GC-specific sibling of [`crate::bias::corrected_effective_length_full`];
671/// see that function for what the convolution and the lower barrier are doing.
672#[allow(clippy::too_many_arguments)]
673pub fn gc_corrected_effective_length(
674    seq: &[u8],
675    prefix: &[u32],
676    cdf: &[f64],
677    fld_low: usize,
678    fld_high: usize,
679    gc_bias: &GcFragModel,
680    seq_models: Option<(&SBModel, &SBModel, &SBModel, &SBModel)>,
681    elen: f64,
682    stride: usize,
683) -> f64 {
684    let k = if seq_models.is_some() {
685        CONTEXT_LENGTH
686    } else {
687        1
688    };
689    let ref_len = seq.len();
690    let unprocessed = (ref_len as i32 - elen as i32).max(0);
691    let cdf_max_arg = (cdf.len() - 1).min(ref_len);
692    let cdf_max_val = cdf[cdf_max_arg];
693    if ref_len < k || unprocessed <= 0 || cdf_max_val < MIN_CDF_MASS {
694        return elen;
695    }
696    let cond = |x: i32| conditional_cdf(cdf, cdf_max_arg, cdf_max_val, x);
697
698    // Per-position sequence-bias factors (1.0 when not seq-correcting).
699    let mut fw = vec![1.0f64; ref_len];
700    let mut rc = vec![1.0f64; ref_len];
701    if let Some((obs_fw, exp_fw, obs_rc, exp_rc)) = seq_models {
702        let cu = CONTEXT_LEFT;
703        let rc_seq = revcomp_bytes(seq);
704        for frag_start in 0..(ref_len - CONTEXT_LENGTH) {
705            let read_start = frag_start + cu;
706            if read_start < ref_len {
707                fw[read_start] = log_bias(
708                    obs_fw,
709                    exp_fw,
710                    &seq[frag_start..frag_start + CONTEXT_LENGTH],
711                    false,
712                )
713                .exp();
714                rc[read_start] = log_bias(
715                    obs_rc,
716                    exp_rc,
717                    &rc_seq[frag_start..frag_start + CONTEXT_LENGTH],
718                    false,
719                )
720                .exp();
721            }
722        }
723        rc.reverse();
724    }
725
726    // Convolve seq×gc fragment factors with the conditional FLD.
727    let ctx = GcContext::build(&GcView::Dense(prefix));
728    let stride = stride.max(1) as i32;
729    let max_len = (ref_len as i32).min(fld_high as i32 + 1);
730    let mut fl = fld_low as i32;
731    let mut done = fl >= max_len;
732    let sp = if fl > 0 { fl - 1 } else { 0 };
733    let mut prev_mass = cond(sp);
734    let mut eff = 0.0f64;
735    while !done {
736        if fl >= max_len {
737            done = true;
738            fl = max_len - 1;
739        }
740        let fl_weight = cond(fl) - prev_mass;
741        prev_mass = cond(fl);
742        let mut mass = 0.0f64;
743        // Hoist the bound: for kstart in [0, kmax) we have
744        // frag_end = kstart+fl-1 <= ref_len-2 < ref_len, so the old per-iteration
745        // `frag_end < ref_len` guard is always true and is dropped (it kept
746        // `ref_len` live in the inner loop, forcing a spill/reload).
747        let kmax = ref_len as i32 - fl;
748        let mut kstart = 0i32;
749        while kstart < kmax {
750            let frag_start = kstart;
751            let frag_end = kstart + fl - 1;
752            let mut frag_factor = fw[frag_start as usize] * rc[frag_end as usize];
753            if let Some((ff, cf)) = ctx.desc(frag_start, frag_end) {
754                frag_factor *= gc_bias.get(ff, cf);
755            }
756            mass += frag_factor;
757            kstart += 1;
758        }
759        eff += fl_weight * mass;
760        fl += stride;
761    }
762
763    let offset = (unprocessed as f64).max(1.0);
764    eff.max(elen.min(offset))
765}
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770
771    /// 101 bins over a 0-100 fraction is one bin per value, i.e. no binning.
772    #[test]
773    fn bin_frac_identity_at_101() {
774        assert_eq!(bin_frac(0, 101), 0);
775        assert_eq!(bin_frac(57, 101), 57);
776        assert_eq!(bin_frac(100, 101), 100);
777    }
778
779    /// Coarse binning must floor, and must clamp 100 into the last bin rather
780    /// than overflowing past it.
781    #[test]
782    fn bin_frac_coarse() {
783        // 3 bins: width 33.33 -> [0,33),[33,66),[66,100]
784        assert_eq!(bin_frac(0, 3), 0);
785        assert_eq!(bin_frac(30, 3), 0);
786        assert_eq!(bin_frac(40, 3), 1);
787        assert_eq!(bin_frac(70, 3), 2);
788        assert_eq!(bin_frac(100, 3), 2); // clamped to n-1
789    }
790
791    /// Each conditioning row is an independent distribution, so each must sum to
792    /// 1 on its own.
793    #[test]
794    fn normalize_makes_rows_sum_to_one() {
795        let mut m = GcFragModel::new(2, 5);
796        for gc in 0..5 {
797            m.inc(gc * 25, 10, (gc + 1) as f64); // row 0 (ctx 10 -> bin 0)
798        }
799        m.normalize();
800        let row0: f64 = (0..5).map(|c| m.counts[c]).sum();
801        assert!((row0 - 1.0).abs() < 1e-9, "row0 sum = {row0}");
802    }
803
804    /// No bias in the data must mean no correction: observed identical to
805    /// expected has to give a factor of exactly 1 everywhere.
806    #[test]
807    fn ratio_of_identical_models_is_one() {
808        let mut obs = GcFragModel::new(1, 101);
809        let mut exp = GcFragModel::new(1, 101);
810        for gc in 0..=100 {
811            obs.inc(gc, 0, (gc + 1) as f64);
812            exp.inc(gc, 0, (gc + 1) as f64);
813        }
814        let r = gc_ratio(&mut obs, &mut exp, GC_MAX_RATIO);
815        for gc in 0..=100 {
816            assert!(
817                (r.get(gc, 0) - 1.0).abs() < 1e-9,
818                "gc {gc}: {}",
819                r.get(gc, 0)
820            );
821        }
822    }
823
824    #[test]
825    fn gc_frac_basic() {
826        // 10-base sequence, 5 G/C -> 50%
827        let seq = b"ACGTACGTAC"; // A C G T A C G T A C : GC at 1,2,5,6,9 = 5
828        let p = gc_prefix(seq);
829        assert_eq!(gc_frac(&p, 0, 9), 50);
830        // all-GC window
831        let seq2 = b"GGGGCCCC";
832        let p2 = gc_prefix(seq2);
833        assert_eq!(gc_frac(&p2, 0, 7), 100);
834        // single base
835        assert_eq!(gc_frac(&p, 2, 2), 100); // 'G'
836        assert_eq!(gc_frac(&p, 0, 0), 0); // 'A'
837    }
838
839    #[test]
840    fn gc_desc_matches_frag_and_context() {
841        // 40-base sequence; check an interior fragment
842        let seq: Vec<u8> = b"ACGT".iter().cycle().take(40).copied().collect();
843        let p = gc_prefix(&seq);
844        let (ff, cf) = gc_desc(&GcView::Dense(&p), 10, 29).unwrap();
845        // ACGT repeat is exactly 50% GC everywhere
846        assert_eq!(ff, 50, "fragFrac");
847        assert!((0..=100).contains(&cf), "contextFrac in range: {cf}");
848        // fragFrac must equal gc_frac over the same interval
849        assert_eq!(ff, gc_frac(&p, 10, 29));
850    }
851
852    /// The lookup table is a performance substitution for the divide-based
853    /// formula, so it has to agree bit-for-bit on every input the model can see,
854    /// including out-of-range ones that must clamp identically.
855    #[test]
856    fn idx_lut_matches_bin_frac() {
857        // The LUT-based idx must equal the original divide-based formula for
858        // every (ctx, gc) fraction the model can see, including out-of-[0,100].
859        for &(cb, gb) in &[(3usize, 25usize), (1, 101), (3, 101), (4, 50)] {
860            let m = GcFragModel::new(cb, gb);
861            for cf in -5i32..=105 {
862                for ff in -5i32..=105 {
863                    let want_ctx = if cb > 1 { bin_frac(cf, cb) } else { 0 };
864                    let want = want_ctx * gb + bin_frac(ff, gb);
865                    assert_eq!(m.idx(cf, ff), want, "cb={cb} gb={gb} cf={cf} ff={ff}");
866                }
867            }
868        }
869    }
870
871    /// Same obligation for the hoisted per-position context cache: it exists only
872    /// to be faster, so any disagreement with the direct computation is a bug.
873    #[test]
874    fn gc_context_matches_gc_desc() {
875        // Cached context (GcContext::desc) must be bit-identical to the
876        // per-fragment gc_desc for every fragment the convolution can visit
877        // (fragStart in 0..ref_len-fl, fragEnd = fragStart+fl-1).
878        let bases = *b"ACGTCGATGC";
879        let seq: Vec<u8> = (0..200).map(|i| bases[(i * 7 + 3) % bases.len()]).collect();
880        let p = gc_prefix(&seq);
881        let v = GcView::Dense(&p);
882        let ctx = GcContext::build(&v);
883        let n = seq.len() as i32;
884        for fl in 1..=120 {
885            let kmax = n - fl;
886            for s in 0..kmax {
887                let e = s + fl - 1;
888                assert_eq!(
889                    ctx.desc(s, e),
890                    gc_desc(&v, s, e),
891                    "mismatch at fl={fl}, s={s}, e={e}"
892                );
893            }
894        }
895    }
896
897    /// And for the rank-bitvector representation: --reduceGCMemory must change
898    /// memory use and nothing else. The offset window also checks the transcript
899    /// is located correctly inside the concatenated buffer.
900    #[test]
901    fn gc_rank_view_matches_dense() {
902        // The rank-bitvector view must return identical cumulative GC (and hence
903        // identical gc_desc) to the dense prefix, for an offset window of a
904        // concatenated buffer.
905        let bases = *b"ACGTGCAT";
906        let pad: Vec<u8> = (0..37).map(|i| bases[(i * 3) % bases.len()]).collect();
907        let seq: Vec<u8> = (0..150).map(|i| bases[(i * 5 + 1) % bases.len()]).collect();
908        // concat = [pad | seq], so the transcript sits at offset pad.len().
909        let mut concat = pad.clone();
910        concat.extend_from_slice(&seq);
911        let p = gc_prefix(&seq);
912        let rank = GcRank::new(&concat);
913        let dense = GcView::Dense(&p);
914        let rview = rank.view(pad.len(), seq.len());
915        for fl in 1..=100 {
916            let n = seq.len() as i32;
917            for s in 0..(n - fl) {
918                let e = s + fl - 1;
919                assert_eq!(
920                    gc_desc(&dense, s, e),
921                    gc_desc(&rview, s, e),
922                    "fl={fl} s={s}"
923                );
924            }
925        }
926    }
927
928    /// Pin the exact window geometry by making only those windows GC-rich: if the
929    /// offsets were off by one the fraction would not come out at exactly 100.
930    #[test]
931    fn gc_desc_context_window_geometry() {
932        // Make the 5' context window [s-3, s+1] all GC and the rest AT, so the
933        // contextFrac is dominated by the GC island near the fragment ends.
934        let mut seq = vec![b'A'; 60];
935        for i in 17..=21 {
936            seq[i] = b'G'; // 5' window for s=20 is [17,21]
937        }
938        for i in 39..=43 {
939            seq[i] = b'C'; // 3' window for e=40 is [39,43]
940        }
941        let p = gc_prefix(&seq);
942        let (_ff, cf) = gc_desc(&GcView::Dense(&p), 20, 40).unwrap();
943        // both 5-base context windows are fully G/C -> 100%
944        assert_eq!(cf, 100, "contextFrac");
945    }
946
947    /// A cell enriched in one model and absent from the other would give an
948    /// unbounded factor; the clamp must contain it.
949    #[test]
950    fn ratio_clamped() {
951        let mut obs = GcFragModel::new(1, 2);
952        let mut exp = GcFragModel::new(1, 2);
953        obs.inc(0, 0, 1e6); // bin 0 hugely enriched in obs
954        exp.inc(75, 0, 1e6); // bin 1 enriched in exp
955        let r = gc_ratio(&mut obs, &mut exp, 1000.0);
956        for v in [r.get(0, 0), r.get(75, 0)] {
957            assert!(
958                (1.0 / 1000.0 - 1e-12..=1000.0 + 1e-6).contains(&v),
959                "ratio {v} out of clamp"
960            );
961        }
962    }
963}