1use realfft::RealFftPlanner;
16use std::cell::RefCell;
17
18thread_local! {
19 static FFT_PLANNER: RefCell<RealFftPlanner<f64>> = RefCell::new(RealFftPlanner::<f64>::new());
23}
24
25fn xcorr_fft(fw: &[f64], rc: &[f64], max_lag: usize) -> Vec<f64> {
30 let l = fw.len();
31 debug_assert_eq!(l, rc.len());
32 let n = (l + max_lag + 1).next_power_of_two().max(2);
33 FFT_PLANNER.with(|p| {
34 let mut planner = p.borrow_mut();
35 let r2c = planner.plan_fft_forward(n);
36 let c2r = planner.plan_fft_inverse(n);
37 let mut a = r2c.make_input_vec();
38 let mut b = r2c.make_input_vec();
39 a[..l].copy_from_slice(fw);
40 b[..l].copy_from_slice(rc);
41 let mut fa = r2c.make_output_vec();
42 let mut fb = r2c.make_output_vec();
43 r2c.process(&mut a, &mut fa).expect("rfft fw");
44 r2c.process(&mut b, &mut fb).expect("rfft rc");
45 for (x, y) in fa.iter_mut().zip(&fb) {
46 *x = x.conj() * *y;
47 }
48 let mut out = c2r.make_output_vec();
49 c2r.process(&mut fa, &mut out).expect("irfft");
50 let scale = 1.0 / n as f64;
51 out[..=max_lag].iter().map(|v| v * scale).collect()
52 })
53}
54
55const ORDER: [u32; 9] = [0, 1, 2, 2, 2, 2, 2, 2, 2];
57pub const CONTEXT_LENGTH: usize = 9;
59pub const CONTEXT_LEFT: usize = 3;
61pub const CONTEXT_RIGHT: usize = 5;
63const ROWS: usize = 64;
65const PRIOR: f64 = 1e-10;
67const LOG_SMALL: f64 = -11.512_925_464_970_229; #[inline]
72fn base2bit(b: u8) -> u32 {
73 match b {
74 b'A' | b'a' => 0,
75 b'C' | b'c' => 1,
76 b'G' | b'g' => 2,
77 b'T' | b't' => 3,
78 _ => 0,
79 }
80}
81
82#[inline]
83fn complement_bit(x: u32) -> u32 {
84 3 - x }
86
87#[derive(Debug, Clone)]
89pub struct SBModel {
90 probs: Vec<f64>,
94 probs_fp: Vec<u64>,
98 marginals: Vec<f64>,
100 shifts: [u32; CONTEXT_LENGTH],
101 masks: [u32; CONTEXT_LENGTH],
102 trained: bool,
103}
104
105impl Default for SBModel {
106 fn default() -> Self {
107 Self::new()
108 }
109}
110
111impl SBModel {
112 pub fn new() -> Self {
113 let mut shifts = [0u32; CONTEXT_LENGTH];
114 let mut masks = [0u32; CONTEXT_LENGTH];
115 for i in 0..CONTEXT_LENGTH {
116 shifts[i] = (2 * CONTEXT_LENGTH as u32) - 2 * (i as u32 + 1);
118 let width = 2 * (ORDER[i] + 1);
119 masks[i] = (1u32 << width) - 1;
120 }
121 Self {
122 probs: vec![PRIOR; ROWS * CONTEXT_LENGTH],
123 probs_fp: vec![0u64; ROWS * CONTEXT_LENGTH],
124 marginals: vec![PRIOR; 4 * CONTEXT_LENGTH],
125 shifts,
126 masks,
127 trained: false,
128 }
129 }
130
131 fn encode(context: &[u8], rev_comp: bool) -> u32 {
134 debug_assert_eq!(context.len(), CONTEXT_LENGTH);
135 let mut mer = 0u32;
136 if rev_comp {
137 for &b in context.iter().rev() {
139 mer = (mer << 2) | complement_bit(base2bit(b));
140 }
141 } else {
142 for &b in context {
143 mer = (mer << 2) | base2bit(b);
144 }
145 }
146 mer
147 }
148
149 #[inline]
150 fn index_at(&self, mer: u32, pos: usize) -> usize {
151 ((mer >> self.shifts[pos]) & self.masks[pos]) as usize
152 }
153
154 pub fn dump(&self) -> &[f64] {
158 &self.probs
159 }
160
161 pub fn add_context(&mut self, context: &[u8], rev_comp: bool, weight: f64) {
163 debug_assert!(!self.trained, "cannot add to a normalized model");
164 let mer = Self::encode(context, rev_comp);
165 let w = crate::bias_mass_to_fp(weight);
166 for pos in 0..CONTEXT_LENGTH {
167 let idx = self.index_at(mer, pos);
168 self.probs_fp[pos * ROWS + idx] += w;
169 }
170 }
171
172 pub fn normalize(&mut self) {
175 if self.trained {
176 return;
177 }
178 for (p, &fp) in self.probs.iter_mut().zip(&self.probs_fp) {
181 *p = PRIOR + fp as f64 / crate::BIAS_WEIGHT_SCALE;
182 }
183 for pos in 0..CONTEXT_LENGTH {
184 let num_states = 4usize.pow(ORDER[pos]);
185 for s in 0..num_states {
186 let node = s * 4;
187 let base = pos * ROWS + node;
188 let tot: f64 = self.probs[base..base + 4].iter().sum();
189 if tot > 0.0 {
190 for j in 0..4 {
191 self.probs[base + j] /= tot;
192 self.marginals[pos * 4 + j] += self.probs[base + j];
193 }
194 }
195 }
196 for j in 0..4 {
197 self.marginals[pos * 4 + j] /= num_states as f64;
198 }
199 }
200 for p in &mut self.probs {
201 *p = if *p > 0.0 { p.ln() } else { LOG_SMALL };
202 }
203 self.trained = true;
204 }
205
206 pub fn evaluate_log(&self, context: &[u8], rev_comp: bool) -> f64 {
208 debug_assert!(self.trained, "evaluate_log requires a normalized model");
209 let mer = Self::encode(context, rev_comp);
210 let mut lp = 0.0;
211 for pos in 0..CONTEXT_LENGTH {
212 let idx = self.index_at(mer, pos);
213 lp += self.probs[pos * ROWS + idx];
214 }
215 lp
216 }
217
218 pub fn is_trained(&self) -> bool {
219 self.trained
220 }
221
222 pub fn combine_counts(&mut self, other: &SBModel) {
225 debug_assert!(!self.trained && !other.trained, "combine before normalize");
226 for (a, b) in self.probs_fp.iter_mut().zip(&other.probs_fp) {
229 *a += *b;
230 }
231 }
232}
233
234pub(crate) fn revcomp_bytes(seq: &[u8]) -> Vec<u8> {
236 seq.iter()
237 .rev()
238 .map(|&b| match b {
239 b'A' | b'a' => b'T',
240 b'C' | b'c' => b'G',
241 b'G' | b'g' => b'C',
242 b'T' | b't' => b'A',
243 _ => b'A',
244 })
245 .collect()
246}
247
248pub(crate) const MIN_ALPHA: f64 = 1e-8;
251pub(crate) const MIN_CDF_MASS: f64 = 1e-10;
253pub const FLD_SAMP_STRIDE: usize = 5;
256
257pub fn fld_cdf_and_bounds(pmf_lin: &[f64]) -> (Vec<f64>, usize, usize) {
261 let mut cdf = vec![0.0f64; pmf_lin.len()];
262 let mut acc = 0.0;
263 let (mut lo, mut hi) = (0usize, 1usize);
264 let (mut lb, mut ub) = (false, false);
265 for i in 0..pmf_lin.len() {
266 acc += pmf_lin[i];
267 cdf[i] = acc;
268 if !lb && acc >= 0.005 {
269 lb = true;
270 lo = i;
271 }
272 if !ub && acc >= 0.995 {
273 ub = true;
274 hi = i;
275 }
276 }
277 (cdf, lo, hi)
278}
279
280#[inline]
285pub(crate) fn conditional_cdf(cdf: &[f64], cdf_max_arg: usize, cdf_max_val: f64, x: i32) -> f64 {
286 if x > cdf_max_arg as i32 {
287 1.0
288 } else if x <= 0 {
289 cdf[0] / cdf_max_val
290 } else {
291 cdf[x as usize] / cdf_max_val
292 }
293}
294
295pub fn build_expected<'a, F>(
301 num_targets: usize,
302 seq_of: F,
303 alphas: &[f64],
304 eff_lens: &[f64],
305 cdf: &[f64],
306) -> (SBModel, SBModel)
307where
308 F: Fn(usize) -> &'a [u8] + Sync,
309{
310 use rayon::prelude::*;
311 let k = CONTEXT_LENGTH;
312 let cu = CONTEXT_LEFT as i32;
313 let per_tid = |tid: usize| -> Option<(SBModel, SBModel)> {
322 if alphas[tid] < MIN_ALPHA || eff_lens[tid] <= 0.0 {
323 return None;
324 }
325 let seq = seq_of(tid);
326 let ref_len = seq.len();
327 if ref_len < k {
328 return None;
329 }
330 let cdf_max_arg = (cdf.len() - 1).min(ref_len);
331 let cdf_max_val = cdf[cdf_max_arg];
332 if cdf_max_val < MIN_CDF_MASS {
333 return None;
334 }
335 let weight = alphas[tid] / eff_lens[tid];
336 let rc = revcomp_bytes(seq);
337 let mut fw = SBModel::new();
338 let mut rc_m = SBModel::new();
339 for frag_start in 0..(ref_len - k) {
341 let max_frag_len = ref_len as i32 - (frag_start as i32 + cu);
342 if max_frag_len >= 0 && (max_frag_len as usize) < ref_len {
343 let cdensity = conditional_cdf(cdf, cdf_max_arg, cdf_max_val, max_frag_len);
344 let w = weight * cdensity;
345 fw.add_context(&seq[frag_start..frag_start + k], false, w);
346 rc_m.add_context(&rc[frag_start..frag_start + k], false, w);
347 }
348 }
349 Some((fw, rc_m))
350 };
351 let (mut exp_fw, mut exp_rc) = (0..num_targets)
352 .into_par_iter()
353 .fold(
354 || (SBModel::new(), SBModel::new()),
355 |mut acc, tid| {
356 if let Some((fw, rc_m)) = per_tid(tid) {
357 acc.0.combine_counts(&fw);
358 acc.1.combine_counts(&rc_m);
359 }
360 acc
361 },
362 )
363 .reduce(
364 || (SBModel::new(), SBModel::new()),
365 |mut a, b| {
366 a.0.combine_counts(&b.0);
367 a.1.combine_counts(&b.1);
368 a
369 },
370 );
371 exp_fw.normalize();
372 exp_rc.normalize();
373 (exp_fw, exp_rc)
374}
375
376#[allow(clippy::too_many_arguments)]
392pub fn corrected_effective_length(
393 seq: &[u8],
394 cdf: &[f64],
395 fld_low: usize,
396 fld_high: usize,
397 obs_fw: &SBModel,
398 exp_fw: &SBModel,
399 obs_rc: &SBModel,
400 exp_rc: &SBModel,
401 elen: f64,
402 stride: usize,
403) -> f64 {
404 let k = CONTEXT_LENGTH;
405 let cu = CONTEXT_LEFT; let ref_len = seq.len();
407 let unprocessed = (ref_len as i32 - elen as i32).max(0);
408 let cdf_max_arg = (cdf.len() - 1).min(ref_len);
409 let cdf_max_val = cdf[cdf_max_arg];
410 if ref_len < k || unprocessed <= 0 || cdf_max_val < MIN_CDF_MASS {
411 return elen;
412 }
413 let cond = |x: i32| conditional_cdf(cdf, cdf_max_arg, cdf_max_val, x);
414
415 let rc_seq = revcomp_bytes(seq);
417 let mut fw = vec![1.0f64; ref_len];
418 let mut rc = vec![1.0f64; ref_len];
419 for frag_start in 0..(ref_len - k) {
420 let read_start = frag_start + cu;
421 if read_start < ref_len {
422 fw[read_start] =
423 log_bias(obs_fw, exp_fw, &seq[frag_start..frag_start + k], false).exp();
424 rc[read_start] =
425 log_bias(obs_rc, exp_rc, &rc_seq[frag_start..frag_start + k], false).exp();
426 }
427 }
428 rc.reverse(); let stride = stride.max(1) as i32;
432 let max_len = (ref_len as i32).min(fld_high as i32 + 1);
433 let mut fl = fld_low as i32;
434 let mut done = fl >= max_len;
435 let sp = if fl > 0 { fl - 1 } else { 0 };
436 let mut prev_mass = cond(sp);
437 let mut eff = 0.0f64;
438 while !done {
439 if fl >= max_len {
440 done = true;
441 fl = max_len - 1;
442 }
443 let fl_weight = cond(fl) - prev_mass;
444 prev_mass = cond(fl);
445 let mut mass = 0.0f64;
446 let mut kstart = 0i32;
447 while kstart < ref_len as i32 - fl {
448 let frag_start = kstart as usize;
449 let frag_end = (kstart + fl - 1) as usize;
450 if frag_end < ref_len {
451 mass += fw[frag_start] * rc[frag_end];
452 } else {
453 break;
454 }
455 kstart += 1;
456 }
457 eff += fl_weight * mass;
458 fl += stride;
459 }
460
461 let offset = (unprocessed as f64).max(1.0);
463 eff.max(elen.min(offset))
464}
465
466#[allow(clippy::too_many_arguments)]
479pub fn eff_len_from_xcorr(
480 a: &[f64],
481 b: &[f64],
482 cond: impl Fn(i32) -> f64,
483 fld_low: usize,
484 fld_high: usize,
485 elen: f64,
486 unprocessed: i32,
487 stride: usize,
488 no_length_threshold: bool,
489) -> f64 {
490 let ref_len = a.len();
491 debug_assert_eq!(ref_len, b.len());
492 let max_len = (ref_len as i32).min(fld_high as i32 + 1);
493 if (fld_low as i32) >= max_len {
494 let offset = (unprocessed as f64).max(1.0);
495 return elen.max(elen.min(offset));
496 }
497 let max_lag = (max_len - 2).max(0) as usize;
502 let xc = xcorr_fft(a, b, max_lag);
503 let b_last = b[ref_len - 1];
504
505 let stride = stride.max(1) as i32;
511 let mut eff = 0.0f64;
512 let mut fl = fld_low as i32;
513 let mut done = fl >= max_len;
514 let sp = if fl > 0 { fl - 1 } else { 0 };
515 let mut prev_mass = cond(sp);
516 while !done {
517 if fl >= max_len {
518 done = true;
519 fl = max_len - 1;
520 }
521 let fl_weight = cond(fl) - prev_mass;
522 prev_mass = cond(fl);
523 if fl >= 1 {
524 let delta = (fl - 1) as usize;
525 let boundary = a[(ref_len as i32 - fl) as usize] * b_last;
526 eff += fl_weight * (xc[delta] - boundary);
527 }
528 fl += stride;
529 }
530 if no_length_threshold {
531 if eff > 1.0 {
532 eff
533 } else {
534 elen
535 }
536 } else {
537 let offset = (unprocessed as f64).max(1.0);
538 eff.max(elen.min(offset))
539 }
540}
541
542#[allow(clippy::too_many_arguments)]
550pub fn corrected_effective_length_fft(
551 seq: &[u8],
552 cdf: &[f64],
553 fld_low: usize,
554 fld_high: usize,
555 obs_fw: &SBModel,
556 exp_fw: &SBModel,
557 obs_rc: &SBModel,
558 exp_rc: &SBModel,
559 elen: f64,
560 stride: usize,
561 no_length_threshold: bool,
562) -> f64 {
563 let k = CONTEXT_LENGTH;
564 let cu = CONTEXT_LEFT;
565 let ref_len = seq.len();
566 let unprocessed = (ref_len as i32 - elen as i32).max(0);
567 let cdf_max_arg = (cdf.len() - 1).min(ref_len);
568 let cdf_max_val = cdf[cdf_max_arg];
569 if ref_len < k || unprocessed <= 0 || cdf_max_val < MIN_CDF_MASS {
570 return elen;
571 }
572 let cond = |x: i32| conditional_cdf(cdf, cdf_max_arg, cdf_max_val, x);
573
574 let rc_seq = revcomp_bytes(seq);
575 let mut fw = vec![1.0f64; ref_len];
576 let mut rc = vec![1.0f64; ref_len];
577 for frag_start in 0..(ref_len - k) {
578 let read_start = frag_start + cu;
579 if read_start < ref_len {
580 fw[read_start] =
581 log_bias(obs_fw, exp_fw, &seq[frag_start..frag_start + k], false).exp();
582 rc[read_start] =
583 log_bias(obs_rc, exp_rc, &rc_seq[frag_start..frag_start + k], false).exp();
584 }
585 }
586 rc.reverse();
587
588 eff_len_from_xcorr(
589 &fw,
590 &rc,
591 cond,
592 fld_low,
593 fld_high,
594 elen,
595 unprocessed,
596 stride,
597 no_length_threshold,
598 )
599}
600
601pub fn log_bias(observed: &SBModel, expected: &SBModel, context: &[u8], rev_comp: bool) -> f64 {
605 observed.evaluate_log(context, rev_comp) - expected.evaluate_log(context, rev_comp)
606}
607
608pub struct LogBiasTable {
623 diff: Vec<f64>,
624 shifts: [u32; CONTEXT_LENGTH],
625 masks: [u32; CONTEXT_LENGTH],
626}
627
628impl LogBiasTable {
629 pub fn new(observed: &SBModel, expected: &SBModel) -> Self {
631 debug_assert!(observed.trained && expected.trained);
632 let diff = observed
633 .probs
634 .iter()
635 .zip(&expected.probs)
636 .map(|(&o, &e)| o - e)
637 .collect();
638 Self {
639 diff,
640 shifts: observed.shifts,
641 masks: observed.masks,
642 }
643 }
644
645 #[inline]
647 pub fn eval(&self, context: &[u8], rev_comp: bool) -> f64 {
648 let mer = SBModel::encode(context, rev_comp);
649 let mut lp = 0.0;
650 for pos in 0..CONTEXT_LENGTH {
651 let idx = ((mer >> self.shifts[pos]) & self.masks[pos]) as usize;
652 lp += self.diff[pos * ROWS + idx];
653 }
654 lp
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661
662 fn trained_pair(seq: &[u8]) -> (SBModel, SBModel) {
665 let rc = revcomp_bytes(seq);
666 let mut exp = SBModel::new();
667 for p in 0..=(seq.len() - CONTEXT_LENGTH) {
668 exp.add_context(&seq[p..p + CONTEXT_LENGTH], false, 1.0);
669 exp.add_context(&rc[p..p + CONTEXT_LENGTH], false, 1.0);
670 }
671 let mut obs = exp.clone();
672 for _ in 0..500 {
673 obs.add_context(b"AAACCCGGG", false, 1.0);
674 obs.add_context(b"TTTGGGCCC", true, 1.0);
675 }
676 obs.normalize();
677 exp.normalize();
678 (obs, exp)
679 }
680
681 #[test]
682 #[ignore = "profiling bench; run with --ignored --nocapture"]
683 fn bench_factor_build() {
684 use std::time::Instant;
685 let bases = b"ACGTACGTAGGCCTTAACCGGTTACGTACGTTTAGCGATCG";
686 let seq: Vec<u8> = (0..2000)
687 .map(|i| bases[(i * 7 + 3) % bases.len()])
688 .collect();
689 let (obs, exp) = trained_pair(&seq);
690 let rc_seq = revcomp_bytes(&seq);
691 let k = CONTEXT_LENGTH;
692 let n = seq.len() - k;
693 let iters = 8000usize; let t = Instant::now();
697 let mut acc = 0.0f64;
698 for _ in 0..iters {
699 for fs in 0..n {
700 acc += log_bias(&obs, &exp, &seq[fs..fs + k], false).exp();
701 acc += log_bias(&obs, &exp, &rc_seq[fs..fs + k], false).exp();
702 }
703 }
704 let v0 = t.elapsed().as_secs_f64();
705
706 let t = Instant::now();
708 let mut acc1 = 0.0f64;
709 for _ in 0..iters {
710 for fs in 0..n {
711 acc1 += log_bias(&obs, &exp, &seq[fs..fs + k], false);
712 acc1 += log_bias(&obs, &exp, &rc_seq[fs..fs + k], false);
713 }
714 }
715 let v_noexp = t.elapsed().as_secs_f64();
716
717 let t = Instant::now();
719 let mut enc = 0u64;
720 for _ in 0..iters {
721 for fs in 0..n {
722 enc ^= SBModel::encode(&seq[fs..fs + k], false) as u64;
723 enc ^= SBModel::encode(&rc_seq[fs..fs + k], false) as u64;
724 }
725 }
726 let v_enc = t.elapsed().as_secs_f64();
727
728 let eval_mer = |m: &SBModel, mer: u32| -> f64 {
731 let mut lp = 0.0;
732 for pos in 0..CONTEXT_LENGTH {
733 lp += m.probs[pos * ROWS + m.index_at(mer, pos)];
734 }
735 lp
736 };
737 let t = Instant::now();
738 let mut acc_v1 = 0.0f64;
739 let mut max_d1 = 0.0f64;
740 for it in 0..iters {
741 for fs in 0..n {
742 let mf = SBModel::encode(&seq[fs..fs + k], false);
743 let mr = SBModel::encode(&rc_seq[fs..fs + k], false);
744 let bf = (eval_mer(&obs, mf) - eval_mer(&exp, mf)).exp();
745 let br = (eval_mer(&obs, mr) - eval_mer(&exp, mr)).exp();
746 acc_v1 += bf + br;
747 if it == 0 {
748 let rf = log_bias(&obs, &exp, &seq[fs..fs + k], false).exp();
749 let rr = log_bias(&obs, &exp, &rc_seq[fs..fs + k], false).exp();
750 max_d1 = max_d1.max((bf - rf).abs()).max((br - rr).abs());
751 }
752 }
753 }
754 let v1 = t.elapsed().as_secs_f64();
755
756 let mut diff = vec![0.0f64; ROWS * CONTEXT_LENGTH];
759 for (i, d) in diff.iter_mut().enumerate() {
760 *d = obs.probs[i] - exp.probs[i];
761 }
762 let eval_diff = |mer: u32| -> f64 {
763 let mut lp = 0.0;
764 for pos in 0..CONTEXT_LENGTH {
765 lp += diff[pos * ROWS + obs.index_at(mer, pos)];
766 }
767 lp
768 };
769 let t = Instant::now();
770 let mut acc_v2 = 0.0f64;
771 let mut max_d2 = 0.0f64;
772 for it in 0..iters {
773 for fs in 0..n {
774 let bf = eval_diff(SBModel::encode(&seq[fs..fs + k], false)).exp();
775 let br = eval_diff(SBModel::encode(&rc_seq[fs..fs + k], false)).exp();
776 acc_v2 += bf + br;
777 if it == 0 {
778 let rf = log_bias(&obs, &exp, &seq[fs..fs + k], false).exp();
779 let rr = log_bias(&obs, &exp, &rc_seq[fs..fs + k], false).exp();
780 max_d2 = max_d2.max((bf - rf).abs()).max((br - rr).abs());
781 }
782 }
783 }
784 let v2 = t.elapsed().as_secs_f64();
785
786 eprintln!("--- factor-build bench ({iters} iters x {n} pos x2) ---");
787 eprintln!("V0 current (log_bias+exp) : {v0:.3}s acc={acc:.3}");
788 eprintln!(
789 " no-exp (log_bias only) : {v_noexp:.3}s acc={acc1:.3} => exp cost ~{:.3}s",
790 v0 - v_noexp
791 );
792 eprintln!(" encode-only (2x/pos) : {v_enc:.3}s enc={enc}");
793 eprintln!("V1 encode-once (byte-ident) : {v1:.3}s acc={acc_v1:.3} max|Δ|={max_d1:.3e} speedup={:.2}x", v0 / v1);
794 eprintln!("V2 diff-table (reassoc) : {v2:.3}s acc={acc_v2:.3} max|Δ|={max_d2:.3e} speedup={:.2}x", v0 / v2);
795 }
796
797 #[test]
798 fn uniform_contexts_give_near_zero_bias() {
799 let ctxs: Vec<Vec<u8>> = (0..256)
801 .map(|i| {
802 let bases = b"ACGT";
803 (0..CONTEXT_LENGTH)
804 .map(|p| bases[((i >> (p * 2)) & 3) as usize])
805 .collect()
806 })
807 .collect();
808 let mut obs = SBModel::new();
809 let mut exp = SBModel::new();
810 for c in &ctxs {
811 obs.add_context(c, false, 1.0);
812 exp.add_context(c, false, 1.0);
813 }
814 obs.normalize();
815 exp.normalize();
816 for c in &ctxs {
817 assert!(log_bias(&obs, &exp, c, false).abs() < 1e-9);
818 }
819 }
820
821 #[test]
822 fn enriched_context_has_positive_bias() {
823 let target: Vec<u8> = b"ACGTACGTA".to_vec();
825 let bases = b"ACGT";
826 let uniform: Vec<Vec<u8>> = (0..4096)
827 .map(|i| {
828 (0..CONTEXT_LENGTH)
829 .map(|p| bases[((i >> (p * 2)) & 3) as usize])
830 .collect()
831 })
832 .collect();
833
834 let mut exp = SBModel::new();
835 for c in &uniform {
836 exp.add_context(c, false, 1.0);
837 }
838 exp.normalize();
839
840 let mut obs = SBModel::new();
841 for c in &uniform {
842 obs.add_context(c, false, 1.0);
843 }
844 for _ in 0..5000 {
845 obs.add_context(&target, false, 1.0); }
847 obs.normalize();
848
849 assert!(
850 log_bias(&obs, &exp, &target, false) > 0.5,
851 "enriched context should have positive log-bias"
852 );
853 }
854
855 #[test]
856 fn unbiased_correction_reduces_to_standard_eff_len() {
857 let bases = b"ACGTACGTAGGCCTTAACCGGTTACGTACGT";
860 let seq: Vec<u8> = (0..400).map(|i| bases[i % bases.len()]).collect();
861 let mut m = SBModel::new();
862 let rc = revcomp_bytes(&seq);
863 for p in 0..=(seq.len() - CONTEXT_LENGTH) {
864 m.add_context(&seq[p..p + CONTEXT_LENGTH], false, 1.0);
865 m.add_context(&rc[p..p + CONTEXT_LENGTH], false, 1.0);
866 }
867 let mut obs = m.clone();
868 let mut exp = m.clone();
869 obs.normalize();
870 exp.normalize();
871
872 let mut pmf = vec![0.0; 200];
873 pmf[100] = 1.0;
874 let (cdf, lo, hi) = fld_cdf_and_bounds(&pmf);
875 let eff = corrected_effective_length(&seq, &cdf, lo, hi, &obs, &exp, &obs, &exp, 300.0, 1);
877 assert!((eff - 300.0).abs() < 1e-6, "got {eff}");
878 }
879
880 #[test]
881 fn fft_matches_exact_scalar_corrected_eff_len() {
882 let bases = b"ACGTACGTAGGCCTTAACCGGTTACGTACGTTTAGCGATCG";
886 let seq: Vec<u8> = (0..1500)
887 .map(|i| bases[(i * 7 + 3) % bases.len()])
888 .collect();
889 let rc = revcomp_bytes(&seq);
890 let mut exp = SBModel::new();
891 for p in 0..=(seq.len() - CONTEXT_LENGTH) {
892 exp.add_context(&seq[p..p + CONTEXT_LENGTH], false, 1.0);
893 exp.add_context(&rc[p..p + CONTEXT_LENGTH], false, 1.0);
894 }
895 let mut obs = exp.clone();
896 let t1 = b"AAACCCGGG";
898 let t2 = b"TTTGGGCCC";
899 for _ in 0..500 {
900 obs.add_context(t1, false, 1.0);
901 obs.add_context(t2, true, 1.0);
902 }
903 obs.normalize();
904 exp.normalize();
905
906 let mut pmf = vec![0.0f64; 600];
908 for (l, v) in pmf.iter_mut().enumerate() {
909 let d = l as f64 - 250.0;
910 *v = (-d * d / (2.0 * 40.0 * 40.0)).exp();
911 }
912 let (cdf, lo, hi) = fld_cdf_and_bounds(&pmf);
913
914 for stride in [1usize, 5] {
917 let scalar = corrected_effective_length(
918 &seq, &cdf, lo, hi, &obs, &exp, &obs, &exp, 1200.0, stride,
919 );
920 let fft = corrected_effective_length_fft(
921 &seq, &cdf, lo, hi, &obs, &exp, &obs, &exp, 1200.0, stride, false,
922 );
923 let rel = (scalar - fft).abs() / scalar.abs();
924 assert!(
925 rel < 1e-9,
926 "FFT vs scalar mismatch at stride={stride}: scalar={scalar} fft={fft} rel={rel:.3e}"
927 );
928 }
929 }
930
931 #[test]
932 fn eff_len_from_xcorr_matches_scalar_combined_factors() {
933 let ref_len = 1300usize;
940 let a: Vec<f64> = (0..ref_len)
941 .map(|i| 0.5 + 1.5 * ((i as f64 * 0.013).sin() * 0.5 + 0.5))
942 .collect();
943 let b: Vec<f64> = (0..ref_len)
944 .map(|i| 0.4 + 1.8 * ((i as f64 * 0.021 + 1.0).cos() * 0.5 + 0.5))
945 .collect();
946
947 let mut pmf = vec![0.0f64; 600];
948 for (l, v) in pmf.iter_mut().enumerate() {
949 let d = l as f64 - 250.0;
950 *v = (-d * d / (2.0 * 40.0 * 40.0)).exp();
951 }
952 let (cdf, lo, hi) = fld_cdf_and_bounds(&pmf);
953 let elen = 1100.0f64;
954 let unprocessed = (ref_len as i32 - elen as i32).max(0);
955 let cdf_max_arg = (cdf.len() - 1).min(ref_len);
956 let cdf_max_val = cdf[cdf_max_arg];
957 let cond = |x: i32| conditional_cdf(&cdf, cdf_max_arg, cdf_max_val, x);
958
959 for stride in [1usize, 5] {
960 let max_len = (ref_len as i32).min(hi as i32 + 1);
963 let st = stride.max(1) as i32;
964 let mut fl = lo as i32;
965 let mut done = fl >= max_len;
966 let sp = if fl > 0 { fl - 1 } else { 0 };
967 let mut prev = cond(sp);
968 let mut eff = 0.0f64;
969 while !done {
970 if fl >= max_len {
971 done = true;
972 fl = max_len - 1;
973 }
974 let w = cond(fl) - prev;
975 prev = cond(fl);
976 let kmax = ref_len as i32 - fl;
977 let mut mass = 0.0f64;
978 let mut k = 0i32;
979 while k < kmax {
980 mass += a[k as usize] * b[(k + fl - 1) as usize];
981 k += 1;
982 }
983 eff += w * mass;
984 fl += st;
985 }
986 let offset = (unprocessed as f64).max(1.0);
987 let scalar = eff.max(elen.min(offset));
988
989 let fft = eff_len_from_xcorr(&a, &b, cond, lo, hi, elen, unprocessed, stride, false);
990 let rel = (scalar - fft).abs() / scalar.abs();
991 assert!(
992 rel < 1e-9,
993 "combined-factor FFT vs scalar mismatch at stride={stride}: scalar={scalar} fft={fft} rel={rel:.3e}"
994 );
995 }
996 }
997
998 #[test]
999 fn revcomp_encoding_is_consistent() {
1000 let ctx: Vec<u8> = b"ACGTACGTA".to_vec();
1002 let rc: Vec<u8> = ctx
1003 .iter()
1004 .rev()
1005 .map(|&b| match b {
1006 b'A' => b'T',
1007 b'C' => b'G',
1008 b'G' => b'C',
1009 b'T' => b'A',
1010 x => x,
1011 })
1012 .collect();
1013 assert_eq!(SBModel::encode(&ctx, true), SBModel::encode(&rc, false));
1014 }
1015
1016 #[test]
1017 fn build_expected_respects_num_targets_bound() {
1018 let bases = b"ACGTACGTAGGCCTTAACCGGTTACGTACGT";
1023 let mut refs: Vec<Vec<u8>> = (0..5)
1024 .map(|s| (0..200).map(|i| bases[(i + s) % bases.len()]).collect())
1025 .collect();
1026 refs.push(
1027 (0..400)
1028 .map(|i| if i % 2 == 0 { b'A' } else { b'C' })
1029 .collect(),
1030 );
1031 let num_refs = refs.len();
1032 let alphas = vec![1.0; num_refs];
1033 let eff_lens = vec![150.0; num_refs];
1034 let mut pmf = vec![0.0; 200];
1035 pmf[100] = 1.0;
1036 let (cdf, _lo, _hi) = fld_cdf_and_bounds(&pmf);
1037
1038 let (a_fw, _) = build_expected(5, |t| refs[t].as_slice(), &alphas, &eff_lens, &cdf);
1040 let (b_fw, _) = build_expected(6, |t| refs[t].as_slice(), &alphas, &eff_lens, &cdf);
1041 assert!(a_fw.is_trained() && b_fw.is_trained());
1042 assert!(a_fw.dump().iter().all(|v| v.is_finite()));
1043 let diff: f64 = a_fw
1044 .dump()
1045 .iter()
1046 .zip(b_fw.dump())
1047 .map(|(x, y)| (x - y).abs())
1048 .sum();
1049 assert!(
1050 diff > 1e-6,
1051 "a target beyond num_targets must not contribute (diff={diff})"
1052 );
1053
1054 let mut alphas0 = alphas.clone();
1057 alphas0[5] = 0.0;
1058 let (c_fw, _) = build_expected(6, |t| refs[t].as_slice(), &alphas0, &eff_lens, &cdf);
1059 let diff2: f64 = a_fw
1060 .dump()
1061 .iter()
1062 .zip(c_fw.dump())
1063 .map(|(x, y)| (x - y).abs())
1064 .sum();
1065 assert!(
1066 diff2 < 1e-9,
1067 "zero-alpha target must not contribute (diff={diff2})"
1068 );
1069 }
1070}