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}