1use anyhow::Result;
23use rayon::prelude::*;
24use rustc_hash::FxHashMap;
25use std::fs::File;
26use std::io::{BufWriter, Write};
27use std::path::Path;
28use std::sync::Mutex;
29
30use crate::seqio::{FastaRecord, FastqFile};
31
32#[derive(Clone)]
48pub struct ExtenderConfig {
49 pub kmer_size: usize,
51 pub num_edge_kmers: usize,
53 pub min_coverage: usize,
55 pub branching_threshold: f64,
58 pub max_n_ratio: f64,
61 pub extension_step: usize,
63 pub max_consecutive_failures: usize,
65 pub max_extension_per_side: usize,
71}
72
73impl Default for ExtenderConfig {
74 fn default() -> Self {
75 Self {
76 kmer_size: 21,
77 num_edge_kmers: 5,
78 min_coverage: 2,
79 branching_threshold: 0.2,
80 max_n_ratio: 0.05,
81 extension_step: 200,
82 max_consecutive_failures: 2,
83 max_extension_per_side: 2000,
84 }
85 }
86}
87
88#[derive(Debug, Clone)]
94pub struct ExtendedContig {
95 pub name: String,
97 pub extended_seq: String,
99}
100
101pub struct ContigExtender {
110 config: ExtenderConfig,
111 reads: std::sync::Arc<Vec<String>>,
114}
115
116impl ContigExtender {
117 pub fn new(config: ExtenderConfig) -> Self {
119 Self {
120 config,
121 reads: std::sync::Arc::new(Vec::new()),
122 }
123 }
124
125 pub fn load_reads_shared(r1_path: &Path, r2_path: &Path) -> Result<std::sync::Arc<Vec<String>>> {
128 eprintln!("Loading reads into memory...");
129
130 let r1_owned = r1_path.to_path_buf();
131 let r2_owned = r2_path.to_path_buf();
132
133 let load = |path: std::path::PathBuf| -> Result<Vec<String>> {
134 let mut reads = Vec::new();
135 let mut reader = FastqFile::open(&path)?;
136 while let Some(record) = reader.read_next()? {
137 reads.push(record.seq);
138 }
139 Ok(reads)
140 };
141 let handle_r1 = std::thread::spawn(move || load(r1_owned));
142 let handle_r2 = std::thread::spawn(move || load(r2_owned));
143
144 let mut reads = handle_r1.join().map_err(|_| anyhow::anyhow!("R1 load thread panicked"))??;
145 let reads_r2 = handle_r2.join().map_err(|_| anyhow::anyhow!("R2 load thread panicked"))??;
146 reads.extend(reads_r2);
147
148 eprintln!("Loaded {} reads into memory", reads.len());
149 Ok(std::sync::Arc::new(reads))
150 }
151
152 pub fn load_reads(&mut self, r1_path: &Path, r2_path: &Path) -> Result<()> {
154 self.reads = Self::load_reads_shared(r1_path, r2_path)?;
155 Ok(())
156 }
157
158 pub fn set_reads(&mut self, reads: std::sync::Arc<Vec<String>>) {
160 self.reads = reads;
161 }
162
163 pub fn extend_contigs(&self, contigs: &[FastaRecord]) -> Result<Vec<ExtendedContig>> {
174 let k = self.config.kmer_size;
175 let max_failures = self.config.max_consecutive_failures;
176
177 let states: Vec<Mutex<ContigState>> = contigs.iter().map(|c| {
179 Mutex::new(ContigState {
180 name: c.name.clone(),
181 current_seq: c.seq.clone(),
182 left_failures: 0,
183 right_failures: 0,
184 left_grown: 0,
185 right_grown: 0,
186 })
187 }).collect();
188
189 let t_start = std::time::Instant::now();
190 let mut rounds = 0usize;
191
192 loop {
193 rounds += 1;
194 let active_indices: Vec<usize> = states.iter().enumerate()
196 .filter(|(_, s)| {
197 let s = s.lock().unwrap();
198 s.left_failures < max_failures || s.right_failures < max_failures
199 })
200 .map(|(i, _)| i)
201 .collect();
202
203 if active_indices.is_empty() {
204 break;
205 }
206
207 let mut edge_kmers: FxHashMap<u64, Vec<(usize, bool, usize)>> = FxHashMap::default();
209
210 for &idx in &active_indices {
211 let state = states[idx].lock().unwrap();
212 let seq = &state.current_seq;
213 if seq.len() < k {
214 continue;
215 }
216
217 if state.left_failures < max_failures {
219 for offset in 0..self.config.num_edge_kmers.min(seq.len() - k + 1) {
220 if let Some(hash) = compute_kmer_hash(&seq[offset..offset+k]) {
221 edge_kmers.entry(hash).or_default().push((idx, true, offset));
222 }
223 }
224 }
225
226 if state.right_failures < max_failures {
228 let seq_len = seq.len();
229 for offset in 0..self.config.num_edge_kmers.min(seq.len() - k + 1) {
230 let start = seq_len - k - offset;
231 if let Some(hash) = compute_kmer_hash(&seq[start..start+k]) {
232 edge_kmers.entry(hash).or_default().push((idx, false, offset));
233 }
234 }
235 }
236 }
237
238 let left_candidates: Mutex<FxHashMap<usize, Vec<[u32; 4]>>> = Mutex::new(FxHashMap::default());
241 let right_candidates: Mutex<FxHashMap<usize, Vec<[u32; 4]>>> = Mutex::new(FxHashMap::default());
242 let max_len = self.config.extension_step;
243
244 self.reads.par_iter().for_each(|read_seq| {
249 scan_read(read_seq, &edge_kmers, &states, k, max_len,
250 &left_candidates, &right_candidates);
251 });
252
253 let left_candidates = left_candidates.into_inner().unwrap();
254 let right_candidates = right_candidates.into_inner().unwrap();
255
256 let any_extended = std::sync::atomic::AtomicBool::new(false);
258
259 active_indices.par_iter().for_each(|&idx| {
260 let mut state = states[idx].lock().unwrap();
261
262 if state.left_failures < max_failures {
264 if let Some(counts) = left_candidates.get(&idx) {
265 let consensus = build_consensus_from_counts(
266 counts,
267 self.config.min_coverage,
268 self.config.branching_threshold,
269 self.config.extension_step,
270 );
271 if !consensus.is_empty() {
272 let n_count = consensus.chars().filter(|&c| c == 'N').count();
273 let n_ratio = n_count as f64 / consensus.len() as f64;
274
275 if n_ratio <= self.config.max_n_ratio {
276 state.current_seq = format!("{}{}", consensus, state.current_seq);
277 state.left_failures = 0;
278 state.left_grown += consensus.len();
279 if state.left_grown >= self.config.max_extension_per_side {
281 state.left_failures = max_failures;
282 }
283 any_extended.store(true, std::sync::atomic::Ordering::Relaxed);
284 } else {
285 state.left_failures += 1;
286 }
287 } else {
288 state.left_failures += 1;
289 }
290 } else {
291 state.left_failures += 1;
292 }
293 }
294
295 if state.right_failures < max_failures {
297 if let Some(counts) = right_candidates.get(&idx) {
298 let consensus = build_consensus_from_counts(
299 counts,
300 self.config.min_coverage,
301 self.config.branching_threshold,
302 self.config.extension_step,
303 );
304 if !consensus.is_empty() {
305 let n_count = consensus.chars().filter(|&c| c == 'N').count();
306 let n_ratio = n_count as f64 / consensus.len() as f64;
307
308 if n_ratio <= self.config.max_n_ratio {
309 state.current_seq = format!("{}{}", state.current_seq, consensus);
310 state.right_failures = 0;
311 state.right_grown += consensus.len();
312 if state.right_grown >= self.config.max_extension_per_side {
314 state.right_failures = max_failures;
315 }
316 any_extended.store(true, std::sync::atomic::Ordering::Relaxed);
317 } else {
318 state.right_failures += 1;
319 }
320 } else {
321 state.right_failures += 1;
322 }
323 } else {
324 state.right_failures += 1;
325 }
326 }
327 });
328
329 if !any_extended.load(std::sync::atomic::Ordering::Relaxed) {
330 break;
331 }
332 }
333
334 eprintln!(
335 " [extender] {} contigs, {} rounds, {} reads, {:.1}s",
336 contigs.len(), rounds, self.reads.len(), t_start.elapsed().as_secs_f64()
337 );
338
339 let results = states.into_iter().map(|s| {
341 let s = s.into_inner().unwrap();
342 ExtendedContig {
343 name: s.name,
344 extended_seq: s.current_seq,
345 }
346 }).collect();
347
348 Ok(results)
349 }
350
351 #[inline]
353 pub fn extend_all_hybrid(&self, contigs: &[FastaRecord]) -> Result<Vec<ExtendedContig>> {
354 self.extend_contigs(contigs)
355 }
356}
357
358struct ContigState {
364 name: String,
365 current_seq: String,
366 left_failures: usize,
367 right_failures: usize,
368 left_grown: usize,
370 right_grown: usize,
371}
372
373fn compute_kmer_hash(kmer: &str) -> Option<u64> {
381 let bytes = kmer.as_bytes();
382 let mut forward = 0u64;
383 let mut reverse = 0u64;
384
385 for (i, &b) in bytes.iter().enumerate() {
386 let base = match b {
387 b'A' | b'a' => 0,
388 b'T' | b't' => 3,
389 b'G' | b'g' => 1,
390 b'C' | b'c' => 2,
391 _ => return None,
392 };
393 forward = (forward << 2) | base;
394 reverse |= (3 - base) << (2 * i);
395 }
396
397 Some(forward.min(reverse))
398}
399
400fn check_kmer_match(read_kmer: &str, contig_kmer: &str) -> (bool, bool) {
404 let is_forward = read_kmer == contig_kmer;
405 let is_revcomp = if is_forward {
406 false
407 } else {
408 reverse_complement(read_kmer) == contig_kmer
409 };
410 (is_forward, is_revcomp)
411}
412
413fn reverse_complement(seq: &str) -> String {
415 seq.chars()
416 .rev()
417 .map(|c| match c.to_ascii_uppercase() {
418 'A' => 'T',
419 'T' => 'A',
420 'G' => 'C',
421 'C' => 'G',
422 _ => 'N',
423 })
424 .collect()
425}
426
427#[allow(clippy::too_many_arguments)]
445fn scan_read(
446 read_seq: &str,
447 edge_kmers: &FxHashMap<u64, Vec<(usize, bool, usize)>>,
448 states: &[Mutex<ContigState>],
449 k: usize,
450 max_len: usize,
451 left_candidates: &Mutex<FxHashMap<usize, Vec<[u32; 4]>>>,
452 right_candidates: &Mutex<FxHashMap<usize, Vec<[u32; 4]>>>,
453) {
454 if read_seq.len() < k {
455 return;
456 }
457 let mut local_left: FxHashMap<usize, Vec<[u32; 4]>> = FxHashMap::default();
458 let mut local_right: FxHashMap<usize, Vec<[u32; 4]>> = FxHashMap::default();
459
460 for i in 0..=(read_seq.len() - k) {
461 let kmer_seq = &read_seq[i..i + k];
462 if let Some(hash) = compute_kmer_hash(kmer_seq) {
463 if let Some(matches) = edge_kmers.get(&hash) {
464 for &(contig_idx, is_left, edge_offset) in matches {
465 let state = states[contig_idx].lock().unwrap();
466 let contig_kmer = if is_left {
467 &state.current_seq[edge_offset..edge_offset + k]
468 } else {
469 let clen = state.current_seq.len();
470 &state.current_seq[clen - k - edge_offset..clen - edge_offset]
471 };
472
473 let (is_forward, is_revcomp) = check_kmer_match(kmer_seq, contig_kmer);
474 drop(state); if is_left {
477 if is_forward && i > edge_offset {
478 let prefix = &read_seq[..i - edge_offset];
479 if !prefix.is_empty() {
480 let ext: String = prefix.chars().rev().collect();
481 accumulate_counts(local_left.entry(contig_idx).or_default(), &ext, max_len);
482 }
483 } else if is_revcomp && i + k + edge_offset < read_seq.len() {
484 let suffix = &read_seq[i + k + edge_offset..];
485 if !suffix.is_empty() {
486 let ext = reverse_complement(suffix);
487 accumulate_counts(local_left.entry(contig_idx).or_default(), &ext, max_len);
488 }
489 }
490 } else if is_forward && i + k + edge_offset < read_seq.len() {
491 let suffix = &read_seq[i + k + edge_offset..];
492 if !suffix.is_empty() {
493 accumulate_counts(local_right.entry(contig_idx).or_default(), suffix, max_len);
494 }
495 } else if is_revcomp && i > edge_offset {
496 let prefix = &read_seq[..i - edge_offset];
497 if !prefix.is_empty() {
498 let ext = reverse_complement(prefix);
499 accumulate_counts(local_right.entry(contig_idx).or_default(), &ext, max_len);
500 }
501 }
502 }
503 }
504 }
505 }
506
507 if !local_left.is_empty() {
508 let mut global = left_candidates.lock().unwrap();
509 for (idx, local_counts) in local_left {
510 merge_counts(global.entry(idx).or_default(), &local_counts);
511 }
512 }
513 if !local_right.is_empty() {
514 let mut global = right_candidates.lock().unwrap();
515 for (idx, local_counts) in local_right {
516 merge_counts(global.entry(idx).or_default(), &local_counts);
517 }
518 }
519}
520
521fn accumulate_counts(counts: &mut Vec<[u32; 4]>, overhang: &str, max_len: usize) {
527 for (i, c) in overhang.bytes().enumerate() {
528 if i >= max_len {
529 break;
530 }
531 let bi = match c.to_ascii_uppercase() {
532 b'A' => 0,
533 b'T' => 1,
534 b'G' => 2,
535 b'C' => 3,
536 _ => continue,
537 };
538 if i >= counts.len() {
539 counts.resize(i + 1, [0; 4]);
540 }
541 counts[i][bi] += 1;
542 }
543}
544
545fn merge_counts(dst: &mut Vec<[u32; 4]>, src: &[[u32; 4]]) {
547 if dst.len() < src.len() {
548 dst.resize(src.len(), [0; 4]);
549 }
550 for (d, s) in dst.iter_mut().zip(src.iter()) {
551 for b in 0..4 {
552 d[b] += s[b];
553 }
554 }
555}
556
557fn build_consensus_from_counts(
564 counts: &[[u32; 4]],
565 min_coverage: usize,
566 branching_threshold: f64,
567 max_len: usize,
568) -> String {
569 let mut result = String::new();
570 for col in counts.iter().take(max_len) {
571 let total: u32 = col.iter().sum();
572 if (total as usize) < min_coverage {
573 break;
574 }
575 let max_idx = col.iter().enumerate().max_by_key(|&(_, &c)| c).map(|(i, _)| i).unwrap_or(0);
576 let mut sorted = *col;
577 sorted.sort_unstable_by(|a, b| b.cmp(a));
578 let minor_freq = sorted[1] as f64 / total as f64;
579 let base = if minor_freq >= branching_threshold {
580 'N'
581 } else {
582 match max_idx {
583 0 => 'A',
584 1 => 'T',
585 2 => 'G',
586 3 => 'C',
587 _ => 'N',
588 }
589 };
590 result.push(base);
591 }
592 result
593}
594
595#[cfg_attr(not(test), allow(dead_code))]
598fn build_consensus_sequence(
599 sequences: &[String],
600 min_coverage: usize,
601 branching_threshold: f64,
602 max_len: usize,
603) -> String {
604 if sequences.is_empty() {
605 return String::new();
606 }
607
608 let actual_max_len = sequences.iter().map(|s| s.len()).max().unwrap_or(0).min(max_len);
609 let mut result = String::new();
610
611 for i in 0..actual_max_len {
612 let bases: Vec<char> = sequences
614 .iter()
615 .filter_map(|s| s.chars().nth(i))
616 .filter(|&c| matches!(c.to_ascii_uppercase(), 'A' | 'T' | 'G' | 'C'))
617 .collect();
618
619 if bases.len() < min_coverage {
620 break;
621 }
622
623 let mut counts = [0usize; 4]; for &b in &bases {
626 match b.to_ascii_uppercase() {
627 'A' => counts[0] += 1,
628 'T' => counts[1] += 1,
629 'G' => counts[2] += 1,
630 'C' => counts[3] += 1,
631 _ => {}
632 }
633 }
634
635 let total = counts.iter().sum::<usize>();
636 let max_idx = counts.iter().enumerate()
637 .max_by_key(|&(_, &c)| c)
638 .map(|(i, _)| i)
639 .unwrap_or(0);
640
641 let mut sorted_counts = counts;
643 sorted_counts.sort_by(|a, b| b.cmp(a));
644 let second_count = sorted_counts[1];
645 let minor_freq = second_count as f64 / total as f64;
646
647 let base = if minor_freq >= branching_threshold {
649 'N' } else {
651 match max_idx {
652 0 => 'A',
653 1 => 'T',
654 2 => 'G',
655 3 => 'C',
656 _ => 'N',
657 }
658 };
659
660 result.push(base);
661 }
662
663 result
664}
665
666pub fn write_extended_contigs(results: &[ExtendedContig], path: &Path) -> Result<()> {
676 let mut writer = BufWriter::new(File::create(path)?);
677
678 for result in results {
679 writeln!(writer, ">{}", result.name)?;
680 writeln!(writer, "{}", result.extended_seq)?;
681 }
682
683 Ok(())
684}
685
686#[cfg(test)]
691mod tests {
692 use super::*;
693
694 #[test]
695 fn test_compute_kmer_hash() {
696 let h1 = compute_kmer_hash("ATGC").unwrap();
698 let h2 = compute_kmer_hash("ATGC").unwrap();
699 assert_eq!(h1, h2);
700
701 let h3 = compute_kmer_hash("GCAT").unwrap();
703 assert_eq!(h1, h3);
704
705 assert!(compute_kmer_hash("ATNG").is_none());
707 }
708
709 #[test]
710 fn test_reverse_complement() {
711 assert_eq!(reverse_complement("ATGC"), "GCAT");
712 assert_eq!(reverse_complement("AAAA"), "TTTT");
713 assert_eq!(reverse_complement(""), "");
714 }
715
716 #[test]
717 fn test_build_consensus() {
718 let seqs = vec![
719 "ATGC".to_string(),
720 "ATGC".to_string(),
721 "ATGC".to_string(),
722 ];
723 let consensus = build_consensus_sequence(&seqs, 2, 0.2, 100);
724 assert_eq!(consensus, "ATGC");
725 }
726
727 #[test]
731 fn test_count_consensus_extension() {
732 let contig = "GTTCAGACCTAGGCATTACGGATCCGATTACGGCATTAGCCATTAGGCAT";
734 let ext_truth = "ACAGTGGTCATGCATGCTAGCTAGCATCGAT";
735 let read = format!("{}{}", &contig[contig.len() - 40..], ext_truth);
737 let contigs = vec![FastaRecord { name: "c1".into(), seq: contig.to_string() }];
738
739 let run = |n_reads: usize| -> String {
740 let mut cfg = ExtenderConfig::default();
741 cfg.max_consecutive_failures = 1;
742 let mut ext = ContigExtender::new(cfg);
743 ext.reads = std::sync::Arc::new(std::iter::repeat(read.clone()).take(n_reads).collect());
744 ext.extend_contigs(&contigs).unwrap()[0].extended_seq.clone()
745 };
746
747 let few = run(50);
748 let many = run(50_000);
749 assert_eq!(few, many, "extension depends on read depth (should not)");
750 assert!(few.len() > contig.len(), "no extension occurred");
752 assert!(few.contains(contig), "original contig not preserved");
753 }
754
755 #[test]
758 fn test_count_consensus_matches_string_version() {
759 let seqs = vec![
760 "ACGT".to_string(),
761 "ACGT".to_string(),
762 "ACTT".to_string(), ];
764 let mut counts: Vec<[u32; 4]> = Vec::new();
765 for s in &seqs {
766 accumulate_counts(&mut counts, s, 100);
767 }
768 let from_counts = build_consensus_from_counts(&counts, 2, 0.2, 100);
769 let from_strings = build_consensus_sequence(&seqs, 2, 0.2, 100);
770 assert_eq!(from_counts, from_strings);
771 }
772
773 fn peak_rss_kb() -> u64 {
775 std::fs::read_to_string("/proc/self/status").ok()
776 .and_then(|s| s.lines().find(|l| l.starts_with("VmHWM"))
777 .and_then(|l| l.split_whitespace().nth(1))
778 .and_then(|v| v.parse().ok()))
779 .unwrap_or(0)
780 }
781
782 #[test]
787 #[ignore]
788 fn bench_count_memory() {
789 let n_reads: usize = std::env::var("NREADS").ok().and_then(|v| v.parse().ok()).unwrap_or(2_000_000);
790 let contig = "GTTCAGACCTAGGCATTACGGATCCGATTACGGCATTAGCCATTAGGCAT";
791 let ext_truth = "ACAGTGGTCATGCATGCTAGCTAGCATCGAT";
792 let read = format!("{}{}", &contig[contig.len() - 40..], ext_truth);
793 let contigs = vec![FastaRecord { name: "c1".into(), seq: contig.to_string() }];
794
795 let mut cfg = ExtenderConfig::default();
796 cfg.max_consecutive_failures = 1;
797 let mut ext = ContigExtender::new(cfg);
798 ext.reads = std::sync::Arc::new(std::iter::repeat(read.clone()).take(n_reads).collect());
799 let reads_rss = peak_rss_kb();
800 let out = ext.extend_contigs(&contigs).unwrap();
801 let after = peak_rss_kb();
802 eprintln!("NREADS={} | reads_loaded={}MB after_extend={}MB | delta_extend={}MB | ext_len={}",
803 n_reads, reads_rss / 1024, after / 1024, (after.saturating_sub(reads_rss)) / 1024, out[0].extended_seq.len());
804 }
805}