Skip to main content

salmon_model/
seqbias.rs

1//! Sequence-specific bias model (`SBModel`).
2//!
3//! # The effect being modelled
4//!
5//! RNA-seq protocols fragment and prime with enzymes and random hexamers that
6//! are not indifferent to sequence. Certain short motifs are cut or primed far
7//! more readily than others, so fragments *start* at some sequences much more
8//! often than at others. A transcript rich in favoured motifs yields more
9//! fragments at the same abundance — again chemistry, not biology.
10//!
11//! # How it is measured
12//!
13//! A faithful port of salmon's `SBModel` (`src/model/SBModel.cpp`): a
14//! variable-order Markov model over the 9-base sequence context surrounding a
15//! fragment's start position (3 bases before the start, the start, and 5 after).
16//! Per-position Markov orders are `{0,1,2,2,2,2,2,2,2}`.
17//!
18//! **What "variable-order Markov" means here.** Rather than one probability for
19//! each of the 4^9 possible 9-mers — which would need far more data than exists —
20//! the model predicts each position from a few preceding ones: position 0 from
21//! nothing (order 0), position 1 from its predecessor (order 1), and the rest
22//! from their two predecessors (order 2). The context's probability is then the
23//! product of nine small conditional probabilities. That is enough structure to
24//! capture real motif preference while keeping the parameter count at 64 per
25//! position, which a run easily estimates.
26//!
27//! Counts are accumulated from observed fragment-start contexts (the *observed*
28//! model) and from the transcriptome (the *expected* model); the ratio of the two
29//! scores the sequence bias at any position, which is used to correct effective
30//! lengths.
31//!
32//! The 2-bit base encoding only needs to be self-consistent (the bias is a
33//! ratio of two models built with the same encoding), so we use A=0, C=1,
34//! G=2, T=3.
35
36use realfft::RealFftPlanner;
37use std::cell::RefCell;
38
39thread_local! {
40    /// Per-thread FFT planner (the per-transcript correction runs in a parallel
41    /// sweep). Padding to a power of two keeps the set of distinct plan sizes
42    /// tiny, so plans are reused across transcripts.
43    static FFT_PLANNER: RefCell<RealFftPlanner<f64>> = RefCell::new(RealFftPlanner::<f64>::new());
44}
45
46/// Cross-correlation `xc[Δ] = Σ_k a[k]·b[k+Δ]` for `Δ in [0, max_lag]`, via a
47/// real FFT (zero-padded so `b` beyond its length contributes 0 — i.e. linear,
48/// not circular, correlation). Correlation theorem: `corr(a,b) =
49/// IFFT(conj(FFT(a))·FFT(b))`. rustfft is unnormalized, so divide by `n`.
50///
51/// **Why this is here.** The effective-length sweep asks, for every fragment
52/// length Δ, "what is the total of (start factor × end factor) over all
53/// positions?". Done directly that is O(L) work per length and O(L²) overall —
54/// prohibitive for a 100 kb transcript. A cross-correlation computes the answer
55/// for *every* Δ at once, and the correlation theorem turns it into three FFTs,
56/// giving O(L log L).
57///
58/// The zero padding matters: without it the FFT would wrap around, so fragments
59/// running off the right end would fold back onto the left end (circular rather
60/// than linear correlation) and produce fragments that do not exist.
61fn xcorr_fft(fw: &[f64], rc: &[f64], max_lag: usize) -> Vec<f64> {
62    let l = fw.len();
63    debug_assert_eq!(l, rc.len());
64    let n = (l + max_lag + 1).next_power_of_two().max(2);
65    FFT_PLANNER.with(|p| {
66        let mut planner = p.borrow_mut();
67        let r2c = planner.plan_fft_forward(n);
68        let c2r = planner.plan_fft_inverse(n);
69        let mut a = r2c.make_input_vec();
70        let mut b = r2c.make_input_vec();
71        a[..l].copy_from_slice(fw);
72        b[..l].copy_from_slice(rc);
73        let mut fa = r2c.make_output_vec();
74        let mut fb = r2c.make_output_vec();
75        r2c.process(&mut a, &mut fa).expect("rfft fw");
76        r2c.process(&mut b, &mut fb).expect("rfft rc");
77        for (x, y) in fa.iter_mut().zip(&fb) {
78            *x = x.conj() * *y;
79        }
80        let mut out = c2r.make_output_vec();
81        c2r.process(&mut fa, &mut out).expect("irfft");
82        let scale = 1.0 / n as f64;
83        out[..=max_lag].iter().map(|v| v * scale).collect()
84    })
85}
86
87/// Per-position Markov orders (salmon's "simple" model). Length is the context.
88///
89/// The first positions necessarily have lower order — there is nothing before
90/// position 0 to condition on, and only one base before position 1.
91const ORDER: [u32; 9] = [0, 1, 2, 2, 2, 2, 2, 2, 2];
92/// Context length (= ORDER.len()): 3 left + start + 5 right.
93pub const CONTEXT_LENGTH: usize = 9;
94/// Bases before the fragment-start position.
95pub const CONTEXT_LEFT: usize = 3;
96/// Bases at/after the fragment-start position.
97pub const CONTEXT_RIGHT: usize = 5;
98/// Rows in the probability table: 4^(maxOrder+1) = 4^3.
99///
100/// One row per (two-base context, predicted base) combination; lower-order
101/// positions use only the first few rows.
102const ROWS: usize = 64;
103/// Pseudocount prior.
104///
105/// Keeps an unobserved transition from being exactly zero, which would make its
106/// log negative infinity and poison every context containing it.
107const PRIOR: f64 = 1e-10;
108/// Floor used when taking the log of a zero probability.
109///
110/// A finite floor rather than -inf, so a single impossible transition cannot
111/// annihilate an otherwise plausible context.
112const LOG_SMALL: f64 = -11.512_925_464_970_229; // ln(1e-5)
113
114/// 2-bit encode an ASCII base (non-ACGT -> 0).
115#[inline]
116fn base2bit(b: u8) -> u32 {
117    match b {
118        b'A' | b'a' => 0,
119        b'C' | b'c' => 1,
120        b'G' | b'g' => 2,
121        b'T' | b't' => 3,
122        _ => 0,
123    }
124}
125
126#[inline]
127fn complement_bit(x: u32) -> u32 {
128    3 - x // A<->T (0<->3), C<->G (1<->2)
129}
130
131/// The sequence-specific bias Markov model.
132#[derive(Debug, Clone)]
133pub struct SBModel {
134    /// log (after [`normalize`](Self::normalize)) or linear (before) transition
135    /// probabilities, laid out position-major: `probs[pos * ROWS + idx]`. Before
136    /// `normalize` this is materialized from the integer `probs_fp`.
137    probs: Vec<f64>,
138    /// Fixed-point integer accumulator for the transition counts (`weight *
139    /// BIAS_WEIGHT_SCALE`, truncated), summed order-independently across worker
140    /// threads and materialized into `probs` (plus the `PRIOR`) at `normalize`.
141    probs_fp: Vec<u64>,
142    /// per-position base marginals: `marginals[pos * 4 + base]`
143    marginals: Vec<f64>,
144    shifts: [u32; CONTEXT_LENGTH],
145    masks: [u32; CONTEXT_LENGTH],
146    trained: bool,
147}
148
149impl Default for SBModel {
150    fn default() -> Self {
151        Self::new()
152    }
153}
154
155impl SBModel {
156    pub fn new() -> Self {
157        let mut shifts = [0u32; CONTEXT_LENGTH];
158        let mut masks = [0u32; CONTEXT_LENGTH];
159        for i in 0..CONTEXT_LENGTH {
160            // base i occupies the high bits; isolate the (order+1)-mer ending at i
161            shifts[i] = (2 * CONTEXT_LENGTH as u32) - 2 * (i as u32 + 1);
162            let width = 2 * (ORDER[i] + 1);
163            masks[i] = (1u32 << width) - 1;
164        }
165        Self {
166            probs: vec![PRIOR; ROWS * CONTEXT_LENGTH],
167            probs_fp: vec![0u64; ROWS * CONTEXT_LENGTH],
168            marginals: vec![PRIOR; 4 * CONTEXT_LENGTH],
169            shifts,
170            masks,
171            trained: false,
172        }
173    }
174
175    /// Encode a 9-base context (`CONTEXT_LENGTH` bytes) into a 2-bit-per-base
176    /// integer with base 0 in the high bits. `rev_comp` reverse-complements it.
177    ///
178    /// Packing the whole context into one `u32` means every per-position lookup
179    /// below is a shift and a mask rather than a slice access.
180    fn encode(context: &[u8], rev_comp: bool) -> u32 {
181        debug_assert_eq!(context.len(), CONTEXT_LENGTH);
182        let mut mer = 0u32;
183        if rev_comp {
184            // reverse complement: last base becomes first
185            for &b in context.iter().rev() {
186                mer = (mer << 2) | complement_bit(base2bit(b));
187            }
188        } else {
189            for &b in context {
190                mer = (mer << 2) | base2bit(b);
191            }
192        }
193        mer
194    }
195
196    #[inline]
197    fn index_at(&self, mer: u32, pos: usize) -> usize {
198        ((mer >> self.shifts[pos]) & self.masks[pos]) as usize
199    }
200
201    /// The flattened transition table (`probs[pos * ROWS + idx]`), for dumping to
202    /// the aux bias files. Linear counts before [`normalize`](Self::normalize),
203    /// conditional log-probabilities after.
204    pub fn dump(&self) -> &[f64] {
205        &self.probs
206    }
207
208    /// Accumulate one observed context with the given weight.
209    pub fn add_context(&mut self, context: &[u8], rev_comp: bool, weight: f64) {
210        debug_assert!(!self.trained, "cannot add to a normalized model");
211        let mer = Self::encode(context, rev_comp);
212        let w = crate::bias_mass_to_fp(weight);
213        for pos in 0..CONTEXT_LENGTH {
214            let idx = self.index_at(mer, pos);
215            self.probs_fp[pos * ROWS + idx] += w;
216        }
217    }
218
219    /// Convert accumulated counts into conditional log-probabilities. Idempotent
220    /// guard: a model can only be normalized once.
221    ///
222    /// Two steps: divide each group of four counts (the four possible bases given
223    /// one preceding context) by their total, turning counts into conditional
224    /// probabilities; then take logs so `evaluate_log` can add instead of
225    /// multiply.
226    pub fn normalize(&mut self) {
227        if self.trained {
228            return;
229        }
230        // Materialize the integer counts into `probs`, reintroducing the `PRIOR`
231        // pseudocount in f64 (matching the pre-fixed-point `probs = PRIOR + Σw`).
232        for (p, &fp) in self.probs.iter_mut().zip(&self.probs_fp) {
233            *p = PRIOR + fp as f64 / crate::BIAS_WEIGHT_SCALE;
234        }
235        for pos in 0..CONTEXT_LENGTH {
236            let num_states = 4usize.pow(ORDER[pos]);
237            for s in 0..num_states {
238                let node = s * 4;
239                let base = pos * ROWS + node;
240                let tot: f64 = self.probs[base..base + 4].iter().sum();
241                if tot > 0.0 {
242                    for j in 0..4 {
243                        self.probs[base + j] /= tot;
244                        self.marginals[pos * 4 + j] += self.probs[base + j];
245                    }
246                }
247            }
248            for j in 0..4 {
249                self.marginals[pos * 4 + j] /= num_states as f64;
250            }
251        }
252        for p in &mut self.probs {
253            *p = if *p > 0.0 { p.ln() } else { LOG_SMALL };
254        }
255        self.trained = true;
256    }
257
258    /// Log-probability the (normalized) model assigns to a context.
259    ///
260    /// A sum of nine per-position log-probabilities, which is the product of the
261    /// nine conditional probabilities — the chain rule for a Markov model.
262    pub fn evaluate_log(&self, context: &[u8], rev_comp: bool) -> f64 {
263        debug_assert!(self.trained, "evaluate_log requires a normalized model");
264        let mer = Self::encode(context, rev_comp);
265        let mut lp = 0.0;
266        for pos in 0..CONTEXT_LENGTH {
267            let idx = self.index_at(mer, pos);
268            lp += self.probs[pos * ROWS + idx];
269        }
270        lp
271    }
272
273    pub fn is_trained(&self) -> bool {
274        self.trained
275    }
276
277    /// Add another (un-normalized) model's counts into this one. Both must be
278    /// pre-normalization; used to merge per-thread observed models.
279    pub fn combine_counts(&mut self, other: &SBModel) {
280        debug_assert!(!self.trained && !other.trained, "combine before normalize");
281        // Integer sum of the raw counts (the PRIOR is reintroduced once, in f64,
282        // at `normalize`), so the merge is order/thread-count independent.
283        for (a, b) in self.probs_fp.iter_mut().zip(&other.probs_fp) {
284            *a += *b;
285        }
286    }
287}
288
289/// Reverse-complement a DNA byte slice (ACGT; other bases map to `A`).
290///
291/// DNA is double-stranded: reading the other strand means walking backwards and
292/// swapping each base for its pair. The 3' end of a fragment is sequenced from
293/// that strand, so its bias is scored against the reverse complement.
294pub(crate) fn revcomp_bytes(seq: &[u8]) -> Vec<u8> {
295    seq.iter()
296        .rev()
297        .map(|&b| match b {
298            b'A' | b'a' => b'T',
299            b'C' | b'c' => b'G',
300            b'G' | b'g' => b'C',
301            b'T' | b't' => b'A',
302            _ => b'A',
303        })
304        .collect()
305}
306
307/// Minimum transcript abundance to contribute to / be corrected by the bias
308/// background (salmon's `minAlpha`).
309pub(crate) const MIN_ALPHA: f64 = 1e-8;
310/// Minimum reliable CDF mass for a transcript (salmon's `minCDFMass`).
311pub(crate) const MIN_CDF_MASS: f64 = 1e-10;
312/// Fragment-length sampling stride in the effective-length convolution
313/// (salmon's `pdfSampFactor` = `biasSpeedSamp` default).
314pub const FLD_SAMP_STRIDE: usize = 5;
315
316/// Linear cumulative fragment-length distribution plus the `[low, high]`
317/// fragment-length quantile bounds (0.5% / 99.5%), mirroring the `cdf`,
318/// `fldLow`, `fldHigh` salmon computes in `updateEffectiveLengths`.
319///
320/// The bounds bracket the lengths worth iterating over: the outer 1% of the
321/// distribution contributes almost nothing to the convolution but would extend
322/// the sweep over a long tail of near-zero-probability lengths.
323pub fn fld_cdf_and_bounds(pmf_lin: &[f64]) -> (Vec<f64>, usize, usize) {
324    let mut cdf = vec![0.0f64; pmf_lin.len()];
325    let mut acc = 0.0;
326    let (mut lo, mut hi) = (0usize, 1usize);
327    let (mut lb, mut ub) = (false, false);
328    for i in 0..pmf_lin.len() {
329        acc += pmf_lin[i];
330        cdf[i] = acc;
331        if !lb && acc >= 0.005 {
332            lb = true;
333            lo = i;
334        }
335        if !ub && acc >= 0.995 {
336            ub = true;
337            hi = i;
338        }
339    }
340    (cdf, lo, hi)
341}
342
343/// Per-transcript conditional fragment-length CDF: salmon's
344/// `conditionalCDF(x) = (x > cdfMaxArg) ? 1.0 : cdf[x] / cdfMaxVal`, where
345/// `cdfMaxArg = min(cdf.len()-1, refLen)` normalizes the FLD to the fragment
346/// lengths that fit in this transcript.
347///
348/// A 500-base transcript cannot host a 700-base fragment, so its length
349/// distribution is the global one restricted to what fits, renormalized to sum to
350/// 1 — otherwise short transcripts would appear to be missing probability mass.
351#[inline]
352pub(crate) fn conditional_cdf(cdf: &[f64], cdf_max_arg: usize, cdf_max_val: f64, x: i32) -> f64 {
353    if x > cdf_max_arg as i32 {
354        1.0
355    } else if x <= 0 {
356        cdf[0] / cdf_max_val
357    } else {
358        cdf[x as usize] / cdf_max_val
359    }
360}
361
362/// Build the expected forward/RC sequence-bias models by sliding the context
363/// window over each expressed transcript. Each context is weighted by the
364/// transcript's abundance density (`alpha / effLen`) times the conditional FLD
365/// mass that can start there (`conditionalCDF(maxFragLen)`), matching salmon's
366/// expected-model construction in `updateEffectiveLengths`.
367///
368/// The "expected" model answers: if fragmentation were indifferent to sequence,
369/// which 9-mers would we see at fragment starts, given these transcripts and
370/// these abundances? Dividing the observed model by it leaves the protocol's
371/// sequence preference alone. Weighting by abundance is essential — a motif that
372/// is common in a highly expressed transcript should be expected to be common.
373pub fn build_expected<'a, F>(
374    num_targets: usize,
375    seq_of: F,
376    alphas: &[f64],
377    eff_lens: &[f64],
378    cdf: &[f64],
379) -> (SBModel, SBModel)
380where
381    F: Fn(usize) -> &'a [u8] + Sync,
382{
383    use rayon::prelude::*;
384    let k = CONTEXT_LENGTH;
385    let cu = CONTEXT_LEFT as i32;
386    // Each expressed transcript contributes independently to the expected
387    // forward/RC context counts, an O(refLen) sweep per transcript. salmon
388    // parallelizes this over transcripts; do the same with rayon (per-thread
389    // `SBModel` partials reduced via `combine_counts`). `seq_of` must be `Sync`
390    // to share across threads (it is: a closure over the index). `num_targets`
391    // excludes decoys (the contiguous tail): decoys are never expressed and so
392    // contribute nothing, but skipping them outright guarantees no O(refLen)
393    // decoy sweep can ever run.
394    let per_tid = |tid: usize| -> Option<(SBModel, SBModel)> {
395        if alphas[tid] < MIN_ALPHA || eff_lens[tid] <= 0.0 {
396            return None;
397        }
398        let seq = seq_of(tid);
399        let ref_len = seq.len();
400        if ref_len < k {
401            return None;
402        }
403        let cdf_max_arg = (cdf.len() - 1).min(ref_len);
404        let cdf_max_val = cdf[cdf_max_arg];
405        if cdf_max_val < MIN_CDF_MASS {
406            return None;
407        }
408        let weight = alphas[tid] / eff_lens[tid];
409        let rc = revcomp_bytes(seq);
410        let mut fw = SBModel::new();
411        let mut rc_m = SBModel::new();
412        // fragStartPos in 0..(refLen - K) (salmon's loop bound)
413        for frag_start in 0..(ref_len - k) {
414            let max_frag_len = ref_len as i32 - (frag_start as i32 + cu);
415            if max_frag_len >= 0 && (max_frag_len as usize) < ref_len {
416                let cdensity = conditional_cdf(cdf, cdf_max_arg, cdf_max_val, max_frag_len);
417                let w = weight * cdensity;
418                fw.add_context(&seq[frag_start..frag_start + k], false, w);
419                rc_m.add_context(&rc[frag_start..frag_start + k], false, w);
420            }
421        }
422        Some((fw, rc_m))
423    };
424    let (mut exp_fw, mut exp_rc) = (0..num_targets)
425        .into_par_iter()
426        .fold(
427            || (SBModel::new(), SBModel::new()),
428            |mut acc, tid| {
429                if let Some((fw, rc_m)) = per_tid(tid) {
430                    acc.0.combine_counts(&fw);
431                    acc.1.combine_counts(&rc_m);
432                }
433                acc
434            },
435        )
436        .reduce(
437            || (SBModel::new(), SBModel::new()),
438            |mut a, b| {
439                a.0.combine_counts(&b.0);
440                a.1.combine_counts(&b.1);
441                a
442            },
443        );
444    exp_fw.normalize();
445    exp_rc.normalize();
446    (exp_fw, exp_rc)
447}
448
449/// Bias-corrected effective length of one transcript, matching salmon's
450/// `updateEffectiveLengths` (`src/util/SalmonUtils.cpp`).
451///
452/// `cdf` is the linear cumulative FLD; `fld_low`/`fld_high` the 0.5%/99.5%
453/// fragment-length quantiles (from [`fld_cdf_and_bounds`]). `elen` is the
454/// transcript's *unbiased* effective length (used for the lower barrier and the
455/// `unprocessedLen` guard). `stride` subsamples fragment lengths
456/// ([`FLD_SAMP_STRIDE`] matches salmon).
457///
458/// Per-position 5'/3' bias factors `exp(obsLog − expLog)` are placed at the
459/// fragment *read-start* (`fragStart + contextBefore`), the 3' factors reversed
460/// to forward fragment-end coordinates, then convolved with the conditional FLD:
461/// `effLen = Σ_l flWeight(l) · Σ_s fw[s]·rc[s+l−1]`. The result is floored at
462/// `min(elen, max(1, unprocessedLen))` (salmon's lower "barrier"; there is no
463/// upper cap, so a strongly-biased transcript's effLen can exceed its length).
464#[allow(clippy::too_many_arguments)]
465pub fn corrected_effective_length(
466    seq: &[u8],
467    cdf: &[f64],
468    fld_low: usize,
469    fld_high: usize,
470    obs_fw: &SBModel,
471    exp_fw: &SBModel,
472    obs_rc: &SBModel,
473    exp_rc: &SBModel,
474    elen: f64,
475    stride: usize,
476) -> f64 {
477    let k = CONTEXT_LENGTH;
478    let cu = CONTEXT_LEFT; // contextBefore(false)
479    let ref_len = seq.len();
480    let unprocessed = (ref_len as i32 - elen as i32).max(0);
481    let cdf_max_arg = (cdf.len() - 1).min(ref_len);
482    let cdf_max_val = cdf[cdf_max_arg];
483    if ref_len < k || unprocessed <= 0 || cdf_max_val < MIN_CDF_MASS {
484        return elen;
485    }
486    let cond = |x: i32| conditional_cdf(cdf, cdf_max_arg, cdf_max_val, x);
487
488    // Per-position 5' and 3' sequence-bias factors, placed at the read-start.
489    let rc_seq = revcomp_bytes(seq);
490    let mut fw = vec![1.0f64; ref_len];
491    let mut rc = vec![1.0f64; ref_len];
492    for frag_start in 0..(ref_len - k) {
493        let read_start = frag_start + cu;
494        if read_start < ref_len {
495            fw[read_start] =
496                log_bias(obs_fw, exp_fw, &seq[frag_start..frag_start + k], false).exp();
497            rc[read_start] =
498                log_bias(obs_rc, exp_rc, &rc_seq[frag_start..frag_start + k], false).exp();
499        }
500    }
501    rc.reverse(); // align RC factors with forward fragment-end coordinates
502
503    // Convolve the bias factors with the conditional FLD over [fld_low, fld_high].
504    let stride = stride.max(1) as i32;
505    let max_len = (ref_len as i32).min(fld_high as i32 + 1);
506    let mut fl = fld_low as i32;
507    let mut done = fl >= max_len;
508    let sp = if fl > 0 { fl - 1 } else { 0 };
509    let mut prev_mass = cond(sp);
510    let mut eff = 0.0f64;
511    while !done {
512        if fl >= max_len {
513            done = true;
514            fl = max_len - 1;
515        }
516        let fl_weight = cond(fl) - prev_mass;
517        prev_mass = cond(fl);
518        let mut mass = 0.0f64;
519        let mut kstart = 0i32;
520        while kstart < ref_len as i32 - fl {
521            let frag_start = kstart as usize;
522            let frag_end = (kstart + fl - 1) as usize;
523            if frag_end < ref_len {
524                mass += fw[frag_start] * rc[frag_end];
525            } else {
526                break;
527            }
528            kstart += 1;
529        }
530        eff += fl_weight * mass;
531        fl += stride;
532    }
533
534    // Lower barrier (salmon default; no upper cap).
535    let offset = (unprocessed as f64).max(1.0);
536    eff.max(elen.min(offset))
537}
538
539/// Bias-corrected effective length when the per-fragment factor is **separable**
540/// as `a[start]·b[end]`. This holds for any combination of sequence and
541/// positional bias (each contributes an independent 5′ start factor and 3′ end
542/// factor); GC bias is *not* separable (its windowed-GC binning couples start
543/// and length) and stays on the scalar convolution.
544///
545/// "Separable" means a fragment's weight depends on where it starts and where it
546/// ends, but not on the two jointly. That is exactly the condition under which
547/// the length sweep becomes a cross-correlation and the FFT applies.
548///
549/// The length sweep `mass(fl) = Σ_k a[k]·b[k+fl-1]` is then the cross-correlation
550/// of `a` and `b` evaluated at every lag, computed once via a real FFT in
551/// `O(L log L)` instead of `O(L · n_len)`. `a`/`b` must both have length
552/// `ref_len`; `cond` is the conditional fragment-length CMF; `unprocessed` is
553/// `max(0, ref_len − elen)`. Mirrors the scalar loop's `stride` (biasSpeedSamp)
554/// sampling and boundary exclusion exactly, so it is a drop-in replacement.
555#[allow(clippy::too_many_arguments)]
556pub fn eff_len_from_xcorr(
557    a: &[f64],
558    b: &[f64],
559    cond: impl Fn(i32) -> f64,
560    fld_low: usize,
561    fld_high: usize,
562    elen: f64,
563    unprocessed: i32,
564    stride: usize,
565    no_length_threshold: bool,
566) -> f64 {
567    let ref_len = a.len();
568    debug_assert_eq!(ref_len, b.len());
569    let max_len = (ref_len as i32).min(fld_high as i32 + 1);
570    if (fld_low as i32) >= max_len {
571        let offset = (unprocessed as f64).max(1.0);
572        return elen.max(elen.min(offset));
573    }
574    // Lags needed: Δ = fl-1 for fl in [fld_low, max_len). The scalar inner loop
575    // stops at kstart < ref_len-fl (so frag_end ≤ ref_len-2), i.e. it excludes
576    // the single fragment ending at ref_len-1; the zero-padded xcorr includes it
577    // (term a[ref_len-fl]·b[ref_len-1]), so subtract that for exact parity.
578    let max_lag = (max_len - 2).max(0) as usize;
579    let xc = xcorr_fft(a, b, max_lag);
580    let b_last = b[ref_len - 1];
581
582    // Mirror the scalar loop's `stride` (biasSpeedSamp) sampling EXACTLY so this
583    // is a drop-in faster replacement, not an accuracy change: same fragment
584    // lengths, same FLD weights. `xc[fl-1] - boundary` is the scalar's inner
585    // position sum (the boundary term is the one fragment ending at ref_len-1
586    // that the scalar's `kstart < ref_len-fl` bound excludes).
587    let stride = stride.max(1) as i32;
588    let mut eff = 0.0f64;
589    let mut fl = fld_low as i32;
590    let mut done = fl >= max_len;
591    let sp = if fl > 0 { fl - 1 } else { 0 };
592    let mut prev_mass = cond(sp);
593    while !done {
594        if fl >= max_len {
595            done = true;
596            fl = max_len - 1;
597        }
598        let fl_weight = cond(fl) - prev_mass;
599        prev_mass = cond(fl);
600        if fl >= 1 {
601            let delta = (fl - 1) as usize;
602            let boundary = a[(ref_len as i32 - fl) as usize] * b_last;
603            eff += fl_weight * (xc[delta] - boundary);
604        }
605        fl += stride;
606    }
607    if no_length_threshold {
608        if eff > 1.0 {
609            eff
610        } else {
611            elen
612        }
613    } else {
614        let offset = (unprocessed as f64).max(1.0);
615        eff.max(elen.min(offset))
616    }
617}
618
619/// FFT form of [`corrected_effective_length`] (sequence-only): builds the 5′/3′
620/// sequence factor arrays, then evaluates the length sweep as their
621/// cross-correlation via [`eff_len_from_xcorr`]. Kept as the validated
622/// seq-only reference (the combined no-GC path in [`crate::bias`] builds the
623/// same factors, optionally fused with positional bias, and calls the same
624/// core). Numerically identical to [`corrected_effective_length`] up to FFT
625/// round-off.
626#[allow(clippy::too_many_arguments)]
627pub fn corrected_effective_length_fft(
628    seq: &[u8],
629    cdf: &[f64],
630    fld_low: usize,
631    fld_high: usize,
632    obs_fw: &SBModel,
633    exp_fw: &SBModel,
634    obs_rc: &SBModel,
635    exp_rc: &SBModel,
636    elen: f64,
637    stride: usize,
638    no_length_threshold: bool,
639) -> f64 {
640    let k = CONTEXT_LENGTH;
641    let cu = CONTEXT_LEFT;
642    let ref_len = seq.len();
643    let unprocessed = (ref_len as i32 - elen as i32).max(0);
644    let cdf_max_arg = (cdf.len() - 1).min(ref_len);
645    let cdf_max_val = cdf[cdf_max_arg];
646    if ref_len < k || unprocessed <= 0 || cdf_max_val < MIN_CDF_MASS {
647        return elen;
648    }
649    let cond = |x: i32| conditional_cdf(cdf, cdf_max_arg, cdf_max_val, x);
650
651    let rc_seq = revcomp_bytes(seq);
652    let mut fw = vec![1.0f64; ref_len];
653    let mut rc = vec![1.0f64; ref_len];
654    for frag_start in 0..(ref_len - k) {
655        let read_start = frag_start + cu;
656        if read_start < ref_len {
657            fw[read_start] =
658                log_bias(obs_fw, exp_fw, &seq[frag_start..frag_start + k], false).exp();
659            rc[read_start] =
660                log_bias(obs_rc, exp_rc, &rc_seq[frag_start..frag_start + k], false).exp();
661        }
662    }
663    rc.reverse();
664
665    eff_len_from_xcorr(
666        &fw,
667        &rc,
668        cond,
669        fld_low,
670        fld_high,
671        elen,
672        unprocessed,
673        stride,
674        no_length_threshold,
675    )
676}
677
678/// Log bias of `observed` relative to `expected` for a context:
679/// `log P_obs(context) - log P_exp(context)`. The fragment-level bias weight is
680/// `exp` of this.
681///
682/// A difference of logs is a ratio of probabilities: how much more often this
683/// context was seen at a fragment start than chance predicts. Above 1 means the
684/// protocol favours it.
685pub fn log_bias(observed: &SBModel, expected: &SBModel, context: &[u8], rev_comp: bool) -> f64 {
686    observed.evaluate_log(context, rev_comp) - expected.evaluate_log(context, rev_comp)
687}
688
689/// Precomputed `observed − expected` log-transition table for a fixed model
690/// pair. The effective-length correction evaluates `log_bias` for a context at
691/// every transcript position; `log_bias` evaluates BOTH models (each
692/// re-encoding the context and sweeping all `CONTEXT_LENGTH` positions), so a
693/// context costs two encodes + two table sweeps. Folding the pair into a single
694/// difference table `diff[pos·ROWS+idx] = obs − exp` (built once per quant run,
695/// the models being fixed during correction) collapses that to **one** encode +
696/// **one** sweep — ~1.4× on the per-position factor build, the dominant cost of
697/// the seqBias sweep.
698///
699/// `eval` equals `log_bias(obs, exp, ctx, rc)` up to floating-point
700/// reassociation: it sums `Σ(obs−exp)` rather than `(Σobs) − (Σexp)`, a
701/// difference of ~1e-15 per context (machine epsilon), far below quant-output
702/// resolution.
703pub struct LogBiasTable {
704    diff: Vec<f64>,
705    shifts: [u32; CONTEXT_LENGTH],
706    masks: [u32; CONTEXT_LENGTH],
707}
708
709impl LogBiasTable {
710    /// Build the difference table from a normalized observed/expected pair.
711    pub fn new(observed: &SBModel, expected: &SBModel) -> Self {
712        debug_assert!(observed.trained && expected.trained);
713        let diff = observed
714            .probs
715            .iter()
716            .zip(&expected.probs)
717            .map(|(&o, &e)| o - e)
718            .collect();
719        Self {
720            diff,
721            shifts: observed.shifts,
722            masks: observed.masks,
723        }
724    }
725
726    /// `log_bias` for a context (one encode, one table sweep).
727    #[inline]
728    pub fn eval(&self, context: &[u8], rev_comp: bool) -> f64 {
729        let mer = SBModel::encode(context, rev_comp);
730        let mut lp = 0.0;
731        for pos in 0..CONTEXT_LENGTH {
732            let idx = ((mer >> self.shifts[pos]) & self.masks[pos]) as usize;
733            lp += self.diff[pos * ROWS + idx];
734        }
735        lp
736    }
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742
743    // Build a realistic-ish trained obs/exp pair: expected from a sweep of the
744    // transcript, observed = expected with a few enriched contexts.
745    //
746    // Starting the observed model as a copy of the expected one means the tests
747    // measure exactly the injected enrichment, with no incidental background
748    // difference between the two.
749    fn trained_pair(seq: &[u8]) -> (SBModel, SBModel) {
750        let rc = revcomp_bytes(seq);
751        let mut exp = SBModel::new();
752        for p in 0..=(seq.len() - CONTEXT_LENGTH) {
753            exp.add_context(&seq[p..p + CONTEXT_LENGTH], false, 1.0);
754            exp.add_context(&rc[p..p + CONTEXT_LENGTH], false, 1.0);
755        }
756        let mut obs = exp.clone();
757        for _ in 0..500 {
758            obs.add_context(b"AAACCCGGG", false, 1.0);
759            obs.add_context(b"TTTGGGCCC", true, 1.0);
760        }
761        obs.normalize();
762        exp.normalize();
763        (obs, exp)
764    }
765
766    /// A profiling harness rather than a correctness test (hence `#[ignore]`):
767    /// it times the successive optimizations of the per-position factor build,
768    /// which is the dominant cost of the seqBias sweep.
769    #[test]
770    #[ignore = "profiling bench; run with --ignored --nocapture"]
771    fn bench_factor_build() {
772        use std::time::Instant;
773        let bases = b"ACGTACGTAGGCCTTAACCGGTTACGTACGTTTAGCGATCG";
774        let seq: Vec<u8> = (0..2000)
775            .map(|i| bases[(i * 7 + 3) % bases.len()])
776            .collect();
777        let (obs, exp) = trained_pair(&seq);
778        let rc_seq = revcomp_bytes(&seq);
779        let k = CONTEXT_LENGTH;
780        let n = seq.len() - k;
781        let iters = 8000usize; // ~16M positions, real-workload scale
782
783        // V0: current path — log_bias (two evaluate_log, each re-encodes) + exp.
784        let t = Instant::now();
785        let mut acc = 0.0f64;
786        for _ in 0..iters {
787            for fs in 0..n {
788                acc += log_bias(&obs, &exp, &seq[fs..fs + k], false).exp();
789                acc += log_bias(&obs, &exp, &rc_seq[fs..fs + k], false).exp();
790            }
791        }
792        let v0 = t.elapsed().as_secs_f64();
793
794        // No-exp: V0 minus the exp() to isolate exp cost.
795        let t = Instant::now();
796        let mut acc1 = 0.0f64;
797        for _ in 0..iters {
798            for fs in 0..n {
799                acc1 += log_bias(&obs, &exp, &seq[fs..fs + k], false);
800                acc1 += log_bias(&obs, &exp, &rc_seq[fs..fs + k], false);
801            }
802        }
803        let v_noexp = t.elapsed().as_secs_f64();
804
805        // Encode-only: isolate the encode cost (2 encodes per position as today).
806        let t = Instant::now();
807        let mut enc = 0u64;
808        for _ in 0..iters {
809            for fs in 0..n {
810                enc ^= SBModel::encode(&seq[fs..fs + k], false) as u64;
811                enc ^= SBModel::encode(&rc_seq[fs..fs + k], false) as u64;
812            }
813        }
814        let v_enc = t.elapsed().as_secs_f64();
815
816        // V1: encode ONCE per context, evaluate obs and exp from the shared mer
817        // (byte-identical: encode is deterministic, sum order unchanged).
818        let eval_mer = |m: &SBModel, mer: u32| -> f64 {
819            let mut lp = 0.0;
820            for pos in 0..CONTEXT_LENGTH {
821                lp += m.probs[pos * ROWS + m.index_at(mer, pos)];
822            }
823            lp
824        };
825        let t = Instant::now();
826        let mut acc_v1 = 0.0f64;
827        let mut max_d1 = 0.0f64;
828        for it in 0..iters {
829            for fs in 0..n {
830                let mf = SBModel::encode(&seq[fs..fs + k], false);
831                let mr = SBModel::encode(&rc_seq[fs..fs + k], false);
832                let bf = (eval_mer(&obs, mf) - eval_mer(&exp, mf)).exp();
833                let br = (eval_mer(&obs, mr) - eval_mer(&exp, mr)).exp();
834                acc_v1 += bf + br;
835                if it == 0 {
836                    let rf = log_bias(&obs, &exp, &seq[fs..fs + k], false).exp();
837                    let rr = log_bias(&obs, &exp, &rc_seq[fs..fs + k], false).exp();
838                    max_d1 = max_d1.max((bf - rf).abs()).max((br - rr).abs());
839                }
840            }
841        }
842        let v1 = t.elapsed().as_secs_f64();
843
844        // V2: precomputed diff table d[pos*ROWS+idx] = obs - exp (one eval per
845        // direction). NON-byte-identical (Σ(a-b) vs Σa-Σb reassociation).
846        let mut diff = vec![0.0f64; ROWS * CONTEXT_LENGTH];
847        for (i, d) in diff.iter_mut().enumerate() {
848            *d = obs.probs[i] - exp.probs[i];
849        }
850        let eval_diff = |mer: u32| -> f64 {
851            let mut lp = 0.0;
852            for pos in 0..CONTEXT_LENGTH {
853                lp += diff[pos * ROWS + obs.index_at(mer, pos)];
854            }
855            lp
856        };
857        let t = Instant::now();
858        let mut acc_v2 = 0.0f64;
859        let mut max_d2 = 0.0f64;
860        for it in 0..iters {
861            for fs in 0..n {
862                let bf = eval_diff(SBModel::encode(&seq[fs..fs + k], false)).exp();
863                let br = eval_diff(SBModel::encode(&rc_seq[fs..fs + k], false)).exp();
864                acc_v2 += bf + br;
865                if it == 0 {
866                    let rf = log_bias(&obs, &exp, &seq[fs..fs + k], false).exp();
867                    let rr = log_bias(&obs, &exp, &rc_seq[fs..fs + k], false).exp();
868                    max_d2 = max_d2.max((bf - rf).abs()).max((br - rr).abs());
869                }
870            }
871        }
872        let v2 = t.elapsed().as_secs_f64();
873
874        eprintln!("--- factor-build bench ({iters} iters x {n} pos x2) ---");
875        eprintln!("V0 current (log_bias+exp)   : {v0:.3}s   acc={acc:.3}");
876        eprintln!(
877            "  no-exp (log_bias only)    : {v_noexp:.3}s  acc={acc1:.3}  => exp cost ~{:.3}s",
878            v0 - v_noexp
879        );
880        eprintln!("  encode-only (2x/pos)      : {v_enc:.3}s   enc={enc}");
881        eprintln!("V1 encode-once (byte-ident) : {v1:.3}s   acc={acc_v1:.3}  max|Δ|={max_d1:.3e}  speedup={:.2}x", v0 / v1);
882        eprintln!("V2 diff-table (reassoc)     : {v2:.3}s   acc={acc_v2:.3}  max|Δ|={max_d2:.3e}  speedup={:.2}x", v0 / v2);
883    }
884
885    /// No enrichment in, no correction out: identical observed and expected
886    /// models must score every context at log-bias ~0 (factor ~1).
887    #[test]
888    fn uniform_contexts_give_near_zero_bias() {
889        // Both models trained on the same uniform set of contexts -> bias ~ 0.
890        let ctxs: Vec<Vec<u8>> = (0..256)
891            .map(|i| {
892                let bases = b"ACGT";
893                (0..CONTEXT_LENGTH)
894                    .map(|p| bases[((i >> (p * 2)) & 3) as usize])
895                    .collect()
896            })
897            .collect();
898        let mut obs = SBModel::new();
899        let mut exp = SBModel::new();
900        for c in &ctxs {
901            obs.add_context(c, false, 1.0);
902            exp.add_context(c, false, 1.0);
903        }
904        obs.normalize();
905        exp.normalize();
906        for c in &ctxs {
907            assert!(log_bias(&obs, &exp, c, false).abs() < 1e-9);
908        }
909    }
910
911    /// And the converse: a context deliberately over-represented in the observed
912    /// model must score positively, so the correction actually responds to bias.
913    #[test]
914    fn enriched_context_has_positive_bias() {
915        // observed enriched for a specific context vs a uniform expected model
916        let target: Vec<u8> = b"ACGTACGTA".to_vec();
917        let bases = b"ACGT";
918        let uniform: Vec<Vec<u8>> = (0..4096)
919            .map(|i| {
920                (0..CONTEXT_LENGTH)
921                    .map(|p| bases[((i >> (p * 2)) & 3) as usize])
922                    .collect()
923            })
924            .collect();
925
926        let mut exp = SBModel::new();
927        for c in &uniform {
928            exp.add_context(c, false, 1.0);
929        }
930        exp.normalize();
931
932        let mut obs = SBModel::new();
933        for c in &uniform {
934            obs.add_context(c, false, 1.0);
935        }
936        for _ in 0..5000 {
937            obs.add_context(&target, false, 1.0); // enrich
938        }
939        obs.normalize();
940
941        assert!(
942            log_bias(&obs, &exp, &target, false) > 0.5,
943            "enriched context should have positive log-bias"
944        );
945    }
946
947    /// With no bias to correct, the corrected effective length must collapse to
948    /// the ordinary one — the correction may not shift results merely by being
949    /// switched on.
950    #[test]
951    fn unbiased_correction_reduces_to_standard_eff_len() {
952        // obs == exp -> all bias factors 1 -> corrected effLen == standard
953        // effLen = sum_l pmf(l)*(refLen - l). Point-mass FLD at l=100.
954        let bases = b"ACGTACGTAGGCCTTAACCGGTTACGTACGT";
955        let seq: Vec<u8> = (0..400).map(|i| bases[i % bases.len()]).collect();
956        let mut m = SBModel::new();
957        let rc = revcomp_bytes(&seq);
958        for p in 0..=(seq.len() - CONTEXT_LENGTH) {
959            m.add_context(&seq[p..p + CONTEXT_LENGTH], false, 1.0);
960            m.add_context(&rc[p..p + CONTEXT_LENGTH], false, 1.0);
961        }
962        let mut obs = m.clone();
963        let mut exp = m.clone();
964        obs.normalize();
965        exp.normalize();
966
967        let mut pmf = vec![0.0; 200];
968        pmf[100] = 1.0;
969        let (cdf, lo, hi) = fld_cdf_and_bounds(&pmf);
970        // unbiased effLen at point-mass 100 on a 400nt transcript = 400 - 100 = 300
971        let eff = corrected_effective_length(&seq, &cdf, lo, hi, &obs, &exp, &obs, &exp, 300.0, 1);
972        assert!((eff - 300.0).abs() < 1e-6, "got {eff}");
973    }
974
975    /// The FFT path exists purely to be faster, so it has to agree with the
976    /// direct scalar convolution to within floating-point round-off — including
977    /// the fiddly boundary term the scalar loop excludes and the FFT includes.
978    #[test]
979    fn fft_matches_exact_scalar_corrected_eff_len() {
980        // Build a genuinely biased obs/exp pair (so per-position factors != 1),
981        // a spread FLD, and check the FFT cross-correlation form equals the exact
982        // (stride=1) scalar convolution up to FFT round-off.
983        let bases = b"ACGTACGTAGGCCTTAACCGGTTACGTACGTTTAGCGATCG";
984        let seq: Vec<u8> = (0..1500)
985            .map(|i| bases[(i * 7 + 3) % bases.len()])
986            .collect();
987        let rc = revcomp_bytes(&seq);
988        let mut exp = SBModel::new();
989        for p in 0..=(seq.len() - CONTEXT_LENGTH) {
990            exp.add_context(&seq[p..p + CONTEXT_LENGTH], false, 1.0);
991            exp.add_context(&rc[p..p + CONTEXT_LENGTH], false, 1.0);
992        }
993        let mut obs = exp.clone();
994        // enrich a couple of contexts so obs != exp
995        let t1 = b"AAACCCGGG";
996        let t2 = b"TTTGGGCCC";
997        for _ in 0..500 {
998            obs.add_context(t1, false, 1.0);
999            obs.add_context(t2, true, 1.0);
1000        }
1001        obs.normalize();
1002        exp.normalize();
1003
1004        // spread FLD (Gaussian-ish around 250)
1005        let mut pmf = vec![0.0f64; 600];
1006        for (l, v) in pmf.iter_mut().enumerate() {
1007            let d = l as f64 - 250.0;
1008            *v = (-d * d / (2.0 * 40.0 * 40.0)).exp();
1009        }
1010        let (cdf, lo, hi) = fld_cdf_and_bounds(&pmf);
1011
1012        // FFT must match the scalar at the SAME stride (drop-in, not an accuracy
1013        // change) — check both the exact (stride=1) and strided (stride=5) cases.
1014        for stride in [1usize, 5] {
1015            let scalar = corrected_effective_length(
1016                &seq, &cdf, lo, hi, &obs, &exp, &obs, &exp, 1200.0, stride,
1017            );
1018            let fft = corrected_effective_length_fft(
1019                &seq, &cdf, lo, hi, &obs, &exp, &obs, &exp, 1200.0, stride, false,
1020            );
1021            let rel = (scalar - fft).abs() / scalar.abs();
1022            assert!(
1023                rel < 1e-9,
1024                "FFT vs scalar mismatch at stride={stride}: scalar={scalar} fft={fft} rel={rel:.3e}"
1025            );
1026        }
1027    }
1028
1029    /// Same obligation for the shared cross-correlation core when sequence and
1030    /// positional factors are fused into one start/end array pair.
1031    #[test]
1032    fn eff_len_from_xcorr_matches_scalar_combined_factors() {
1033        // The generic core handles ANY separable per-fragment factor
1034        // a[start]·b[end] — i.e. seq-only, pos-only, or seq+pos (GC is not
1035        // separable). Validate it against an explicit scalar double-loop on
1036        // arbitrary positive factor arrays (a stand-in for seqFW·posFW etc.),
1037        // at both stride 1 and 5, so the pos / seq+pos dispatch in `bias.rs` is
1038        // covered independently of how the factors were built.
1039        let ref_len = 1300usize;
1040        let a: Vec<f64> = (0..ref_len)
1041            .map(|i| 0.5 + 1.5 * ((i as f64 * 0.013).sin() * 0.5 + 0.5))
1042            .collect();
1043        let b: Vec<f64> = (0..ref_len)
1044            .map(|i| 0.4 + 1.8 * ((i as f64 * 0.021 + 1.0).cos() * 0.5 + 0.5))
1045            .collect();
1046
1047        let mut pmf = vec![0.0f64; 600];
1048        for (l, v) in pmf.iter_mut().enumerate() {
1049            let d = l as f64 - 250.0;
1050            *v = (-d * d / (2.0 * 40.0 * 40.0)).exp();
1051        }
1052        let (cdf, lo, hi) = fld_cdf_and_bounds(&pmf);
1053        let elen = 1100.0f64;
1054        let unprocessed = (ref_len as i32 - elen as i32).max(0);
1055        let cdf_max_arg = (cdf.len() - 1).min(ref_len);
1056        let cdf_max_val = cdf[cdf_max_arg];
1057        let cond = |x: i32| conditional_cdf(&cdf, cdf_max_arg, cdf_max_val, x);
1058
1059        for stride in [1usize, 5] {
1060            // Explicit scalar reference: same fragment lengths/weights and the
1061            // same `kstart < ref_len - fl` bound as the combined scalar loop.
1062            let max_len = (ref_len as i32).min(hi as i32 + 1);
1063            let st = stride.max(1) as i32;
1064            let mut fl = lo as i32;
1065            let mut done = fl >= max_len;
1066            let sp = if fl > 0 { fl - 1 } else { 0 };
1067            let mut prev = cond(sp);
1068            let mut eff = 0.0f64;
1069            while !done {
1070                if fl >= max_len {
1071                    done = true;
1072                    fl = max_len - 1;
1073                }
1074                let w = cond(fl) - prev;
1075                prev = cond(fl);
1076                let kmax = ref_len as i32 - fl;
1077                let mut mass = 0.0f64;
1078                let mut k = 0i32;
1079                while k < kmax {
1080                    mass += a[k as usize] * b[(k + fl - 1) as usize];
1081                    k += 1;
1082                }
1083                eff += w * mass;
1084                fl += st;
1085            }
1086            let offset = (unprocessed as f64).max(1.0);
1087            let scalar = eff.max(elen.min(offset));
1088
1089            let fft = eff_len_from_xcorr(&a, &b, cond, lo, hi, elen, unprocessed, stride, false);
1090            let rel = (scalar - fft).abs() / scalar.abs();
1091            assert!(
1092                rel < 1e-9,
1093                "combined-factor FFT vs scalar mismatch at stride={stride}: scalar={scalar} fft={fft} rel={rel:.3e}"
1094            );
1095        }
1096    }
1097
1098    /// The 3' factor is scored against the reverse complement, so encoding a
1099    /// context reverse-complemented must equal encoding its reverse complement
1100    /// directly; otherwise the two ends would be scored against different models.
1101    #[test]
1102    fn revcomp_encoding_is_consistent() {
1103        // RC of a context evaluated forward equals the context evaluated as RC.
1104        let ctx: Vec<u8> = b"ACGTACGTA".to_vec();
1105        let rc: Vec<u8> = ctx
1106            .iter()
1107            .rev()
1108            .map(|&b| match b {
1109                b'A' => b'T',
1110                b'C' => b'G',
1111                b'G' => b'C',
1112                b'T' => b'A',
1113                x => x,
1114            })
1115            .collect();
1116        assert_eq!(SBModel::encode(&ctx, true), SBModel::encode(&rc, false));
1117    }
1118
1119    /// Decoys sit past `num_targets` and must never enter the expected model: a
1120    /// genome decoy swept as a transcript would dominate the background.
1121    #[test]
1122    fn build_expected_respects_num_targets_bound() {
1123        // Five real transcripts plus a sixth "decoy" with a very distinctive
1124        // composition (poly-AC). The decoy must influence the expected model only
1125        // when `num_targets` includes it, and must be skipped (cheaply) when its
1126        // alpha is zero — the two ways decoys are kept out of the bias models.
1127        let bases = b"ACGTACGTAGGCCTTAACCGGTTACGTACGT";
1128        let mut refs: Vec<Vec<u8>> = (0..5)
1129            .map(|s| (0..200).map(|i| bases[(i + s) % bases.len()]).collect())
1130            .collect();
1131        refs.push(
1132            (0..400)
1133                .map(|i| if i % 2 == 0 { b'A' } else { b'C' })
1134                .collect(),
1135        );
1136        let num_refs = refs.len();
1137        let alphas = vec![1.0; num_refs];
1138        let eff_lens = vec![150.0; num_refs];
1139        let mut pmf = vec![0.0; 200];
1140        pmf[100] = 1.0;
1141        let (cdf, _lo, _hi) = fld_cdf_and_bounds(&pmf);
1142
1143        // Exclude the decoy (num_targets = 5) vs include it (num_targets = 6).
1144        let (a_fw, _) = build_expected(5, |t| refs[t].as_slice(), &alphas, &eff_lens, &cdf);
1145        let (b_fw, _) = build_expected(6, |t| refs[t].as_slice(), &alphas, &eff_lens, &cdf);
1146        assert!(a_fw.is_trained() && b_fw.is_trained());
1147        assert!(a_fw.dump().iter().all(|v| v.is_finite()));
1148        let diff: f64 = a_fw
1149            .dump()
1150            .iter()
1151            .zip(b_fw.dump())
1152            .map(|(x, y)| (x - y).abs())
1153            .sum();
1154        assert!(
1155            diff > 1e-6,
1156            "a target beyond num_targets must not contribute (diff={diff})"
1157        );
1158
1159        // With the decoy's alpha zeroed the MIN_ALPHA guard skips it, so including
1160        // it (num_targets = 6) must match excluding it (num_targets = 5).
1161        let mut alphas0 = alphas.clone();
1162        alphas0[5] = 0.0;
1163        let (c_fw, _) = build_expected(6, |t| refs[t].as_slice(), &alphas0, &eff_lens, &cdf);
1164        let diff2: f64 = a_fw
1165            .dump()
1166            .iter()
1167            .zip(c_fw.dump())
1168            .map(|(x, y)| (x - y).abs())
1169            .sum();
1170        assert!(
1171            diff2 < 1e-9,
1172            "zero-alpha target must not contribute (diff={diff2})"
1173        );
1174    }
1175}