Skip to main content

fastqc_rust/modules/
overrepresented_seqs.rs

1// Overrepresented Sequences module
2// Corresponds to Modules/OverRepresentedSeqs.java
3
4use std::collections::HashMap;
5use std::io;
6use std::sync::{Arc, Mutex};
7
8use crate::config::{Limits, LimitsExt};
9use crate::modules::QCModule;
10use crate::sequence::Sequence;
11use crate::utils::format::java_format_double;
12use crate::utils::java_hashmap;
13
14/// Shared data between OverRepresentedSeqs and DuplicationLevel.
15/// In Java, DuplicationLevel directly accesses OverRepresentedSeqs' fields.
16#[derive(Default)]
17pub struct OverRepresentedData {
18    /// Map of truncated sequence -> count
19    pub sequences: HashMap<String, u64>,
20    /// Total number of sequences processed
21    pub count: u64,
22    /// Total count at the point where we reached the unique sequence limit
23    pub count_at_unique_limit: u64,
24}
25
26impl OverRepresentedData {
27    pub fn new() -> Self {
28        Self::default()
29    }
30}
31
32/// Result of contaminant matching for an overrepresented sequence.
33struct ContaminantHit {
34    name: String,
35    length: usize,
36    percent_id: usize,
37}
38
39impl ContaminantHit {
40    /// Returns true if this hit is better (longer match or higher identity at same length)
41    /// than `other`, or if `other` is None.
42    /// Replicates the comparison logic used throughout Contaminant.findMatch().
43    fn is_better_than(&self, other: &Option<ContaminantHit>) -> bool {
44        match other {
45            None => true,
46            Some(b) => {
47                self.length > b.length
48                    || (self.length == b.length && self.percent_id > b.percent_id)
49            }
50        }
51    }
52}
53
54impl std::fmt::Display for ContaminantHit {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        // Format matches ContaminantHit.toString()
57        write!(
58            f,
59            "{} ({}% over {}bp)",
60            self.name, self.percent_id, self.length
61        )
62    }
63}
64
65/// A single overrepresented sequence entry for reporting.
66struct OverrepresentedSeq {
67    seq: String,
68    count: u64,
69    percentage: f64,
70    contaminant_hit: Option<ContaminantHit>,
71}
72
73/// A contaminant entry loaded from the contaminant list.
74struct Contaminant {
75    name: String,
76    forward: Vec<u8>,
77    reverse: Vec<u8>,
78}
79
80impl Contaminant {
81    fn new(name: &str, sequence: &str) -> Self {
82        let forward: Vec<u8> = sequence.to_uppercase().bytes().collect();
83        // Reverse complement computed exactly as in Contaminant.java
84        let mut reverse = vec![0u8; forward.len()];
85        for (c, &base) in forward.iter().enumerate() {
86            let rev_pos = (forward.len() - 1) - c;
87            reverse[rev_pos] = match base {
88                b'G' => b'C',
89                b'A' => b'T',
90                b'T' => b'A',
91                b'C' => b'G',
92                _ => base,
93            };
94        }
95        Contaminant {
96            name: name.to_string(),
97            forward,
98            reverse,
99        }
100    }
101
102    /// Replicates Contaminant.findMatch() - for short sequences (<20bp, >=8bp)
103    /// checks if query is a substring of the contaminant. For longer sequences, slides with
104    /// 1 mismatch tolerance and requires >=20bp match length.
105    fn find_match(&self, query: &str) -> Option<ContaminantHit> {
106        let query_upper = query.to_uppercase();
107
108        // Special case for short queries (8-19bp) - exact substring match
109        if query_upper.len() < 20 && query_upper.len() >= 8 {
110            let forward_str = std::str::from_utf8(&self.forward).unwrap_or("");
111            let reverse_str = std::str::from_utf8(&self.reverse).unwrap_or("");
112
113            if forward_str.contains(&query_upper) {
114                return Some(ContaminantHit {
115                    name: self.name.clone(),
116                    length: query_upper.len(),
117                    percent_id: 100,
118                });
119            }
120            if reverse_str.contains(&query_upper) {
121                return Some(ContaminantHit {
122                    name: self.name.clone(),
123                    length: query_upper.len(),
124                    percent_id: 100,
125                });
126            }
127        }
128
129        let q: Vec<u8> = query_upper.bytes().collect();
130        let mut best_hit: Option<ContaminantHit> = None;
131
132        // Check forward strand with sliding window and 1 mismatch tolerance
133        best_hit = Self::find_strand_match(&self.forward, &q, best_hit, &self.name);
134        best_hit = Self::find_strand_match(&self.reverse, &q, best_hit, &self.name);
135
136        best_hit
137    }
138
139    fn find_strand_match(
140        ca: &[u8],
141        cb: &[u8],
142        mut best_hit: Option<ContaminantHit>,
143        name: &str,
144    ) -> Option<ContaminantHit> {
145        let min_offset = -(ca.len() as isize - 20);
146        let max_offset = cb.len() as isize - 20;
147
148        for offset in min_offset..max_offset {
149            if let Some(hit) = Self::find_match_at_offset(ca, cb, offset, name) {
150                if hit.is_better_than(&best_hit) {
151                    best_hit = Some(hit);
152                }
153            }
154        }
155        best_hit
156    }
157
158    /// Replicates the private findMatch(char[], char[], int, int) method.
159    fn find_match_at_offset(
160        ca: &[u8],
161        cb: &[u8],
162        offset: isize,
163        name: &str,
164    ) -> Option<ContaminantHit> {
165        let mut best_hit: Option<ContaminantHit> = None;
166        let mut mismatch_count: usize = 0;
167        // Use isize to avoid underflow when offset causes start > end
168        let mut start: isize = 0;
169        let mut end: isize = 0;
170
171        // index i used to access both ca[i] and cb[i+offset]
172        for (i, &ca_byte) in ca.iter().enumerate() {
173            let j = i as isize + offset;
174            if j < 0 {
175                start = i as isize + 1;
176                continue;
177            }
178            if j >= cb.len() as isize {
179                break;
180            }
181
182            if ca_byte == cb[j as usize] {
183                end = i as isize;
184            } else {
185                mismatch_count += 1;
186                if mismatch_count > 1 {
187                    // Check if match so far is worth recording (>20bp)
188                    if end >= start {
189                        let match_len = (1 + end - start) as usize;
190                        if match_len > 20 {
191                            let id = ((match_len - (mismatch_count - 1)) * 100) / match_len;
192                            let candidate = ContaminantHit {
193                                name: name.to_string(),
194                                length: match_len,
195                                percent_id: id,
196                            };
197                            if candidate.is_better_than(&best_hit) {
198                                best_hit = Some(candidate);
199                            }
200                        }
201                    }
202                    start = i as isize + 1;
203                    end = i as isize + 1;
204                    mismatch_count = 0;
205                }
206            }
207        }
208
209        // Check final stretch
210        if end < start {
211            return best_hit;
212        }
213        let match_len = (1 + end - start) as usize;
214        if match_len > 20 {
215            let id = ((match_len - mismatch_count) * 100) / match_len;
216            let candidate = ContaminantHit {
217                name: name.to_string(),
218                length: match_len,
219                percent_id: id,
220            };
221            if candidate.is_better_than(&best_hit) {
222                best_hit = Some(candidate);
223            }
224        }
225
226        best_hit
227    }
228}
229
230/// Find the best contaminant match for a query sequence.
231/// Replicates ContaminentFinder.findContaminantHit()
232fn find_contaminant_hit(query: &str, contaminants: &[Contaminant]) -> Option<ContaminantHit> {
233    let mut best_hit: Option<ContaminantHit> = None;
234
235    for contaminant in contaminants {
236        if let Some(hit) = contaminant.find_match(query) {
237            if hit.is_better_than(&best_hit) {
238                best_hit = Some(hit);
239            }
240        }
241    }
242
243    best_hit
244}
245
246pub struct OverRepresentedSeqs {
247    pub shared_data: Arc<Mutex<OverRepresentedData>>,
248    /// Maximum 100000 unique sequences tracked
249    unique_sequence_count: usize,
250    frozen: bool,
251    dup_length: usize,
252    contaminants: Vec<Contaminant>,
253    limits: Limits,
254    // Lazily computed
255    computed: Option<Vec<OverrepresentedSeq>>,
256}
257
258/// Maximum number of unique sequences to track
259const OBSERVATION_CUTOFF: usize = 100_000;
260
261impl OverRepresentedSeqs {
262    pub fn new(
263        limits: &Limits,
264        dup_length: usize,
265        contaminant_entries: &[(String, String)],
266        shared_data: Arc<Mutex<OverRepresentedData>>,
267    ) -> Self {
268        let contaminants: Vec<Contaminant> = contaminant_entries
269            .iter()
270            .map(|(name, seq)| Contaminant::new(name, seq))
271            .collect();
272
273        OverRepresentedSeqs {
274            shared_data,
275            unique_sequence_count: 0,
276            frozen: false,
277            dup_length,
278            contaminants,
279            limits: limits.clone(),
280            computed: None,
281        }
282    }
283
284    fn get_overrepresented_seqs(&mut self) {
285        if self.computed.is_some() {
286            return;
287        }
288
289        let warn_threshold = self.limits.threshold("overrepresented\twarn", 0.1);
290
291        // Use into_inner on PoisonError to recover the guard even after a panic in
292        // another module -- losing the analysis is worse than using slightly stale data.
293        let data = self.shared_data.lock().unwrap_or_else(|e| e.into_inner());
294        let total_count = data.count;
295
296        let mut keepers: Vec<OverrepresentedSeq> = Vec::new();
297
298        for (seq, &count) in &data.sequences {
299            let percentage = (count as f64 / total_count as f64) * 100.0;
300            if percentage > warn_threshold {
301                let hit = find_contaminant_hit(seq, &self.contaminants);
302                keepers.push(OverrepresentedSeq {
303                    seq: seq.clone(),
304                    count,
305                    percentage,
306                    contaminant_hit: hit,
307                });
308            }
309        }
310
311        // JAVA COMPAT: Java's stable sort by count leaves ties in `HashMap<String, Long>` order.
312        let capacity = java_hashmap::table_capacity(data.sequences.len());
313        keepers.sort_by_cached_key(|k| {
314            let bucket = java_hashmap::bucket(java_hashmap::string_hash(&k.seq), capacity);
315            (std::cmp::Reverse(k.count), bucket, k.seq.clone())
316        });
317
318        self.computed = Some(keepers);
319    }
320
321    fn ensure_calculated(&self) -> &[OverrepresentedSeq] {
322        self.computed.as_deref().unwrap_or(&[])
323    }
324}
325
326impl QCModule for OverRepresentedSeqs {
327    fn cost_hint(&self) -> u32 {
328        4
329    }
330
331    fn process_sequence(&mut self, sequence: &Sequence) {
332        self.computed = None;
333
334        let mut data = self.shared_data.lock().unwrap_or_else(|e| e.into_inner());
335        data.count += 1;
336
337        // Truncate sequence to dup_length or 50bp if longer than 50
338        // Safety: sequence bytes are ASCII (uppercased in Sequence::new), so slicing is valid UTF-8
339        let seq_bytes = &sequence.sequence;
340        let truncate_len = if self.dup_length != 0 && seq_bytes.len() > self.dup_length {
341            self.dup_length
342        } else if seq_bytes.len() > 50 {
343            // Default truncation to 50bp for sequences longer than 50
344            50
345        } else {
346            seq_bytes.len()
347        };
348        let seq = std::str::from_utf8(&seq_bytes[..truncate_len]).unwrap_or("");
349
350        if let Some(count) = data.sequences.get_mut(seq) {
351            *count += 1;
352            // Keep updating countAtUniqueLimit while not frozen
353            if !self.frozen {
354                data.count_at_unique_limit = data.count;
355            }
356        } else if !self.frozen {
357            data.sequences.insert(seq.to_string(), 1);
358            self.unique_sequence_count += 1;
359            data.count_at_unique_limit = data.count;
360            if self.unique_sequence_count == OBSERVATION_CUTOFF {
361                self.frozen = true;
362            }
363        }
364    }
365
366    fn finalize(&mut self) {
367        self.get_overrepresented_seqs();
368    }
369
370    fn name(&self) -> &str {
371        "Overrepresented sequences"
372    }
373
374    fn description(&self) -> &str {
375        "Identifies sequences which are overrepresented in the set"
376    }
377
378    fn reset(&mut self) {
379        let mut data = self.shared_data.lock().unwrap_or_else(|e| e.into_inner());
380        data.count = 0;
381        data.count_at_unique_limit = 0;
382        data.sequences.clear();
383        self.unique_sequence_count = 0;
384        self.frozen = false;
385        self.computed = None;
386    }
387
388    fn raises_error(&self) -> bool {
389        let error_threshold = self.limits.threshold("overrepresented\terror", 1.0);
390        let seqs = self.ensure_calculated();
391        // Check if the top sequence exceeds error threshold
392        seqs.first().is_some_and(|s| s.percentage > error_threshold)
393    }
394
395    fn raises_warning(&self) -> bool {
396        let seqs = self.ensure_calculated();
397        // Any overrepresented sequence triggers a warning
398        !seqs.is_empty()
399    }
400
401    fn ignore_filtered_sequences(&self) -> bool {
402        true
403    }
404
405    fn ignore_in_report(&self) -> bool {
406        self.limits.is_ignored("overrepresented")
407    }
408
409    fn write_html_report(&self, writer: &mut dyn io::Write, _png: bool) -> io::Result<()> {
410        if self.ensure_calculated().is_empty() {
411            return write!(writer, "<p>No overrepresented sequences</p>");
412        }
413        let mut text = Vec::new();
414        self.write_text_report(&mut text)?;
415        crate::report::html::write_default_html_table(&String::from_utf8_lossy(&text), writer)
416    }
417
418    fn write_text_report(&self, writer: &mut dyn io::Write) -> io::Result<()> {
419        let seqs = self.ensure_calculated();
420
421        // When there are no overrepresented sequences, Java writes nothing
422        // to the data section (no header, no rows). The header is only output when
423        // writeTable() is called, which is skipped when overrepresntedSeqs.length == 0.
424        if seqs.is_empty() {
425            return Ok(());
426        }
427
428        writeln!(writer, "#Sequence\tCount\tPercentage\tPossible Source")?;
429        for s in seqs {
430            let source = match &s.contaminant_hit {
431                Some(hit) => hit.to_string(),
432                None => "No Hit".to_string(),
433            };
434            // JAVA COMPAT: Math.round(p * 100.0) / 100.0, then Double.toString(),
435            // so "0.5" not "0.50". f64::round matches Math.round for positives.
436            writeln!(
437                writer,
438                "{}\t{}\t{}\t{}",
439                s.seq,
440                s.count,
441                java_format_double((s.percentage * 100.0).round() / 100.0),
442                source
443            )?;
444        }
445
446        Ok(())
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn test_ties_in_java_order() {
456        // Expected order from Java FastQC on 12 sequences seen 20 times among 2000 singletons.
457        let java_order = [
458            "GCGGTGTTAAGTGTCGAGCTACATCACTTCTCATGTAGCC",
459            "CAGATTTTCATATTATGCAGAAAATCTACTTCGCCTGATA",
460            "GATCCTATGCTTGTGAGTACCCAGAAAATAGCGACGGACC",
461            "CCGGGGCTAATCCGTCATTGTCAAGAGACATCTTTCGTCT",
462            "CGAGTCGGTTATCTTCGGATACTGTATAGTCCCACCTGGT",
463            "GATGTCAAACCCCGGGGGGAGCTCAGATATCCGATACAGG",
464            "AGAAAAGGTTCAGACCCCGGAGCCCAGCCGTCACGATTGT",
465            "GATGAAGAAATAACCTCATCCCATTGGTGACGAAAGGTTG",
466            "TAAGTAGCTGGCCGCCGAGATAGCTGAGCGGCGAACCACT",
467            "CATTAGGCTACTAACGCCGCCGGGTCGTTACTCGAAAAGC",
468            "AGAAGGCTGCAACTCATCGACTCTATGTAGTGACCGCGTC",
469            "TATGCGTATAAGCCCGGTTCACTACGTCCGTTCTGGCAAG",
470        ];
471        let mut data = OverRepresentedData::new();
472        data.count = 2240;
473        for seq in java_order.iter().rev() {
474            data.sequences.insert(seq.to_string(), 20);
475        }
476        for i in 0..2000 {
477            data.sequences.insert(format!("S{i}"), 1);
478        }
479
480        let shared = Arc::new(Mutex::new(data));
481        let mut module = OverRepresentedSeqs::new(&Limits::new(), 0, &[], shared);
482        module.get_overrepresented_seqs();
483        let order: Vec<&str> = module
484            .ensure_calculated()
485            .iter()
486            .map(|k| k.seq.as_str())
487            .collect();
488        assert_eq!(order, java_order);
489    }
490}