Skip to main content

argenus/
extender.rs

1//! Contig Extender Module
2//!
3//! Provides k-mer based contig extension using paired-end reads.
4//! Extends assembled contigs by finding overlapping reads at contig edges.
5//!
6//! # Extension Modes
7//! - **Strict mode**: High coverage requirement, low branching tolerance.
8//!   Use for high-confidence extensions where accuracy is critical.
9//!   Parameters: `min_coverage=3`, `branching_threshold=0.1`, `max_n_ratio=0.02`
10//!
11//! - **Relaxed mode**: Lower coverage, higher branching tolerance.
12//!   Use for unresolved cases where longer extensions are needed.
13//!   Parameters: `min_coverage=2`, `branching_threshold=0.2`, `max_n_ratio=0.05`
14//!
15//! # Algorithm Overview
16//! 1. Build k-mer index from contig edges
17//! 2. Scan reads for matching k-mers
18//! 3. Collect extension candidates from overlapping reads
19//! 4. Build consensus sequence with branching detection
20//! 5. Repeat until no further extension possible
21
22use 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// ============================================================================
33// Configuration
34// ============================================================================
35
36/// Configuration parameters for contig extension.
37///
38/// # Strict vs Relaxed Mode
39/// Strict mode (default) prioritises accuracy over length.
40/// Relaxed mode allows more aggressive extension for difficult cases.
41///
42/// | Parameter           | Strict | Relaxed | Description                    |
43/// |---------------------|--------|---------|--------------------------------|
44/// | min_coverage        | 3      | 2       | Minimum read support           |
45/// | branching_threshold | 0.1    | 0.2     | Minor allele frequency for N   |
46/// | max_n_ratio         | 0.02   | 0.05    | Maximum allowed N proportion   |
47#[derive(Clone)]
48pub struct ExtenderConfig {
49    /// K-mer size for extension (default: 21).
50    pub kmer_size: usize,
51    /// Number of edge k-mers to use from each end (default: 5).
52    pub num_edge_kmers: usize,
53    /// Minimum read coverage for consensus building (default: 2).
54    pub min_coverage: usize,
55    /// Branching threshold: if minor allele frequency >= this, mark as N (default: 0.2).
56    /// Lower values = stricter, fewer Ns allowed.
57    pub branching_threshold: f64,
58    /// Maximum N ratio allowed in extension (default: 0.05).
59    /// Extensions exceeding this are rejected.
60    pub max_n_ratio: f64,
61    /// Extension length per iteration in base pairs (default: 200).
62    pub extension_step: usize,
63    /// Maximum consecutive failures before stopping (default: 2).
64    pub max_consecutive_failures: usize,
65    /// Cap on total bp added to EACH contig end (default: 2000). Genus/species
66    /// classification only uses ~max_flanking (1000 bp) of flanking, so extending a
67    /// runaway (repetitive/high-coverage) contig for many more rounds is wasted work
68    /// — and re-scanning all reads each round is the dominant runtime. Capping each
69    /// side stops runaways early without affecting classification.
70    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// ============================================================================
89// Results
90// ============================================================================
91
92/// Result of extension for a single contig.
93#[derive(Debug, Clone)]
94pub struct ExtendedContig {
95    /// Original contig name.
96    pub name: String,
97    /// Extended sequence (original + extensions).
98    pub extended_seq: String,
99}
100
101// ============================================================================
102// Contig Extender
103// ============================================================================
104
105/// K-mer based contig extender using paired-end reads.
106///
107/// Loads reads into memory for efficient repeated scanning.
108/// Supports both sequential and parallel extension strategies.
109pub struct ContigExtender {
110    config: ExtenderConfig,
111    /// Read sequences (R1 then R2), shared so a single load can back multiple
112    /// extension passes (strict + flexible) without re-reading the FASTQs.
113    reads: std::sync::Arc<Vec<String>>,
114}
115
116impl ContigExtender {
117    /// Creates a new contig extender with the given configuration.
118    pub fn new(config: ExtenderConfig) -> Self {
119        Self {
120            config,
121            reads: std::sync::Arc::new(Vec::new()),
122        }
123    }
124
125    /// Loads paired-end reads (R1 then R2) into a shared `Arc<Vec<String>>`, in
126    /// parallel from both files. Reusable across extender instances.
127    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    /// Loads reads from the two FASTQs into this extender.
153    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    /// Reuses an already-loaded shared read set (see `load_reads_shared`).
159    pub fn set_reads(&mut self, reads: std::sync::Arc<Vec<String>>) {
160        self.reads = reads;
161    }
162
163    /// Extends all contigs using hybrid parallel strategy.
164    ///
165    /// Combines shared read scanning with parallel extension application.
166    /// This is the recommended method for most use cases.
167    ///
168    /// # Algorithm
169    /// 1. Build edge k-mer index for active contigs
170    /// 2. Parallel read scanning (shared across all contigs)
171    /// 3. Parallel extension application per contig
172    /// 4. Repeat until no contigs can be extended
173    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        // Use Arc<Mutex> for thread-safe state updates
178        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            // Identify contigs that still need extension
195            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            // Build edge k-mer index for active contigs
208            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                // Index left edge k-mers
218                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                // Index right edge k-mers
227                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            // Accumulate per-position base counts (not raw reads) so peak memory is
239            // O(extension_step) per contig edge regardless of read depth.
240            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            // Stream all reads (no index): metagenomic reads have mostly-distinct
245            // k-mers, so building a read index costs far more (tens of millions of
246            // per-k-mer allocations) than it saves — measured ~26x slower. The
247            // allocation-free streaming scan is the right structure here.
248            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            // Parallel extension application
257            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                // Try left extension
263                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                                // Stop this side once it has enough flanking (cap).
280                                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                // Try right extension
296                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                                // Stop this side once it has enough flanking (cap).
313                                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        // Convert states to results
340        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    /// Alias for extend_contigs() - hybrid parallel method.
352    #[inline]
353    pub fn extend_all_hybrid(&self, contigs: &[FastaRecord]) -> Result<Vec<ExtendedContig>> {
354        self.extend_contigs(contigs)
355    }
356}
357
358// ============================================================================
359// Internal State
360// ============================================================================
361
362/// Internal state for each contig during extension.
363struct ContigState {
364    name: String,
365    current_seq: String,
366    left_failures: usize,
367    right_failures: usize,
368    /// bp added to each end so far (for the per-side extension cap).
369    left_grown: usize,
370    right_grown: usize,
371}
372
373// ============================================================================
374// K-mer Utilities
375// ============================================================================
376
377/// Computes canonical k-mer hash (minimum of forward and reverse complement).
378///
379/// Returns None if the k-mer contains non-ATGC characters.
380fn 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
400/// Checks if a read k-mer matches a contig k-mer (forward or reverse complement).
401///
402/// Returns (is_forward_match, is_reverse_complement_match).
403fn 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
413/// Computes the reverse complement of a DNA sequence.
414fn 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// ============================================================================
428// Consensus Building
429// ============================================================================
430
431/// Builds consensus sequence from multiple extension candidates.
432///
433/// Uses positional voting with branching detection.
434/// Positions with minor allele frequency >= threshold are marked as N.
435///
436/// # Arguments
437/// * `sequences` - Extension candidates from overlapping reads
438/// * `min_coverage` - Minimum bases required at each position
439/// * `branching_threshold` - Minor allele frequency threshold for N
440/// * `max_len` - Maximum consensus length
441/// Scan one read against the contig-edge k-mer index and accumulate its overhangs
442/// (as per-position base counts) into the shared left/right count matrices. Shared
443/// by both the full-scan and indexed dispatch paths so they produce identical output.
444#[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); // Release lock early
475
476                    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
521/// Accumulate one oriented overhang's bases into a per-position [A,T,G,C] count
522/// matrix (index 0 = first base past the contig edge). Non-ACGT bases are skipped
523/// (matching build_consensus_sequence). Positions beyond `max_len` are ignored.
524/// This lets extension consensus be built incrementally without ever storing the
525/// raw reads — bounding memory to O(max_len) per contig edge regardless of depth.
526fn 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
545/// Element-wise add a (thread-local) count matrix into a shared one.
546fn 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
557/// Consensus from a per-position [A,T,G,C] count matrix. Identical decision logic
558/// to `build_consensus_sequence` (majority base per position; 'N' when the minor
559/// allele frequency >= branching_threshold; stop at the first position with
560/// coverage < min_coverage), but reads all positions from accumulated counts so no
561/// raw reads are retained. Using ALL reads (no truncation) makes the consensus
562/// exact, not a sampled approximation.
563fn 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/// Reference (string-based) consensus, retained as the correctness oracle for
596/// `build_consensus_from_counts` (see test_count_consensus_matches_string_version).
597#[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        // Collect bases at this position
613        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        // Count base frequencies
624        let mut counts = [0usize; 4]; // A, T, G, C
625        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        // Check for branching (second most common base)
642        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        // Determine consensus base
648        let base = if minor_freq >= branching_threshold {
649            'N' // Ambiguous position
650        } 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
666// ============================================================================
667// Output Functions
668// ============================================================================
669
670/// Writes extended contigs to a FASTA file.
671///
672/// # Arguments
673/// * `results` - Extended contig results
674/// * `path` - Output file path
675pub 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// ============================================================================
687// Tests
688// ============================================================================
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693
694    #[test]
695    fn test_compute_kmer_hash() {
696        // Same k-mer should produce same hash
697        let h1 = compute_kmer_hash("ATGC").unwrap();
698        let h2 = compute_kmer_hash("ATGC").unwrap();
699        assert_eq!(h1, h2);
700
701        // Reverse complement should produce same canonical hash
702        let h3 = compute_kmer_hash("GCAT").unwrap();
703        assert_eq!(h1, h3);
704
705        // K-mer with N should return None
706        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    /// Count-based consensus must extend correctly and be independent of read depth:
728    /// 50 vs 50000 agreeing reads give the SAME extension (the count matrix uses all
729    /// reads, no truncation). This is the memory fix — raw reads are never stored.
730    #[test]
731    fn test_count_consensus_extension() {
732        // Non-repetitive contig + a distinct right-side extension truth.
733        let contig = "GTTCAGACCTAGGCATTACGGATCCGATTACGGCATTAGCCATTAGGCAT";
734        let ext_truth = "ACAGTGGTCATGCATGCTAGCTAGCATCGAT";
735        // Reads share the contig's right edge and carry the extension.
736        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        // And the extension actually happened (grew past the original contig).
751        assert!(few.len() > contig.len(), "no extension occurred");
752        assert!(few.contains(contig), "original contig not preserved");
753    }
754
755    /// The count matrix must reproduce build_consensus_sequence exactly (majority
756    /// base, 'N' on branching, stop below min_coverage).
757    #[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(), // position 2 branches: G vs T
763        ];
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    /// Peak RSS (VmHWM, KB) of this process, from /proc/self/status.
774    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    /// Micro-benchmark (node-load-independent): the per-position count matrix bounds
783    /// extension memory regardless of read depth — the explosion (millions of reads
784    /// on ONE contig edge) now adds ~0. Run with `--ignored --nocapture`; vary NREADS
785    /// to confirm the RSS delta stays flat.
786    #[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}