Skip to main content

fgumi_metrics/
rejection.rs

1//! Rejection reason tracking for reads and templates.
2//!
3//! This module provides rejection reason types for tracking why reads or templates
4//! are rejected during processing, enabling detailed metrics and debugging.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9/// Reasons why a read or template was rejected during processing.
10///
11/// Each variant represents a specific reason for rejection, allowing for
12/// detailed tracking and reporting of why data was filtered out.
13// `EnumIter` (test-only) auto-generates `RejectionReason::iter()` so coverage assertions
14// enumerate every variant without a hand-maintained list that can silently drift.
15#[cfg_attr(test, derive(strum::EnumIter))]
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub enum RejectionReason {
18    /// Insufficient reads to generate a consensus
19    InsufficientSupport,
20    /// Reads has a different, and minority, set of indels
21    MinorityAlignment,
22    /// Too few reads agreed on the strand orientation
23    InsufficientStrandSupport,
24    /// Base quality scores were below threshold
25    LowBaseQuality,
26    /// Read group had too many N bases
27    ExcessiveNBases,
28    /// Template had no valid alignments
29    NoValidAlignment,
30    /// Reads failed mapping quality threshold
31    LowMappingQuality,
32    /// UMI contained N bases
33    NBasesInUmi,
34    /// Read lacks required UMI tag
35    MissingUmi,
36    /// Reads were marked as not passing filter (PF flag)
37    NotPassingFilter,
38    /// Consensus read had too low mean quality
39    LowMeanQuality,
40    /// Consensus read had insufficient minimum depth
41    InsufficientMinDepth,
42    /// Consensus read had excessive error rate
43    ExcessiveErrorRate,
44    /// UMI was too short
45    UmiTooShort,
46    /// Template had reads on same strand only (no proper pair)
47    SameStrandOnly,
48    /// Unpaired/fragment reads supplied to the duplex/codec caller
49    NonPairedReads,
50    /// Potential collision between independent duplex molecules sharing an MI
51    DuplicateUmi,
52    /// Only one of R1 or R2 consensus was generated (orphan)
53    OrphanConsensus,
54    /// Read had zero bases after quality trimming
55    ZeroBasesPostTrimming,
56    /// Template did not have a single primary FR pair of reads (codec)
57    NotPrimaryFrPair,
58    /// Overlap between R1s and R2s too short for CODEC calling (fgbio
59    /// `r1_r2_overlap_too_short`; used by codec)
60    R1R2OverlapTooShort,
61    /// Indel error between the top/bottom strands (fgbio
62    /// `indel_error_between_strands`; used by codec)
63    IndelErrorBetweenStrands,
64    /// Too many errors between the top/bottom strands (fgbio
65    /// `high_duplex_disagreement`; used by codec)
66    HighDuplexDisagreement,
67    /// Overlap clipping failed (fgbio `clip_overlap_failed`; used by codec)
68    ClipOverlapFailed,
69}
70
71impl RejectionReason {
72    /// Returns a human-readable description.
73    #[must_use]
74    pub fn description(&self) -> &'static str {
75        match self {
76            Self::InsufficientSupport => "Insufficient reads to generate a consensus",
77            // fgbio spells this "Reads has …" (sic) — matched verbatim for metric parity.
78            Self::MinorityAlignment => "Reads has a different, and minority, set of indels",
79            Self::InsufficientStrandSupport => "Too few reads agreed on the strand orientation",
80            Self::LowBaseQuality => "Base quality scores were below threshold",
81            Self::ExcessiveNBases => "Read group had too many N bases",
82            Self::NoValidAlignment => "Template had no valid alignments",
83            Self::LowMappingQuality => "Reads failed mapping quality threshold",
84            Self::NBasesInUmi => "UMI contained N bases",
85            Self::MissingUmi => "Read lacks required UMI tag",
86            Self::NotPassingFilter => "Reads were marked as not passing filter",
87            Self::LowMeanQuality => "Consensus read had too low mean quality",
88            Self::InsufficientMinDepth => "Consensus read had insufficient minimum depth",
89            Self::ExcessiveErrorRate => "Consensus read had excessive error rate",
90            Self::UmiTooShort => "UMI was too short",
91            Self::SameStrandOnly => "Template had reads on same strand only",
92            Self::NonPairedReads => "Unpaired/fragment reads not supported by Duplex caller",
93            Self::DuplicateUmi => "Potential collision between independent duplex molecules",
94            Self::OrphanConsensus => "Only one of R1 or R2 consensus generated",
95            Self::ZeroBasesPostTrimming => "Read or mate had zero bases post trimming",
96            Self::NotPrimaryFrPair => "Template did not have a single primary FR pair of reads",
97            Self::R1R2OverlapTooShort => "Overlap between R1s and R2s too short for CODEC calling",
98            Self::IndelErrorBetweenStrands => "Indel error between top/bottom strands",
99            // fgbio's exact string (note the "top/bottoms" typo), matched for parity.
100            Self::HighDuplexDisagreement => "Too many errors between top/bottoms strands",
101            Self::ClipOverlapFailed => "See https://github.com/fulcrumgenomics/fgbio/issues/1090",
102        }
103    }
104
105    /// Returns the TSV metric key for this rejection reason.
106    #[must_use]
107    pub fn tsv_key(&self) -> &'static str {
108        match self {
109            Self::InsufficientSupport => "raw_reads_rejected_for_insufficient_support",
110            Self::MinorityAlignment => "raw_reads_rejected_for_minority_alignment",
111            Self::InsufficientStrandSupport => "raw_reads_rejected_for_insufficient_strand_support",
112            Self::LowBaseQuality => "raw_reads_rejected_for_low_base_quality",
113            Self::ExcessiveNBases => "raw_reads_rejected_for_excessive_n_bases",
114            Self::NoValidAlignment => "raw_reads_rejected_for_no_valid_alignment",
115            Self::LowMappingQuality => "raw_reads_rejected_for_low_mapping_quality",
116            Self::NBasesInUmi => "raw_reads_rejected_for_n_bases_in_umi",
117            Self::MissingUmi => "raw_reads_rejected_for_missing_umi",
118            Self::NotPassingFilter => "raw_reads_rejected_for_not_passing_filter",
119            Self::LowMeanQuality => "raw_reads_rejected_for_low_mean_quality",
120            Self::InsufficientMinDepth => "raw_reads_rejected_for_insufficient_min_depth",
121            Self::ExcessiveErrorRate => "raw_reads_rejected_for_excessive_error_rate",
122            Self::UmiTooShort => "raw_reads_rejected_for_umi_too_short",
123            Self::SameStrandOnly => "raw_reads_rejected_for_single_strand_only",
124            Self::NonPairedReads => "raw_reads_rejected_for_non_paired_reads",
125            Self::DuplicateUmi => "raw_reads_rejected_for_potential_umi_collision",
126            Self::OrphanConsensus => "raw_reads_rejected_for_orphan_consensus",
127            Self::ZeroBasesPostTrimming => "raw_reads_rejected_for_zero_bases_post_trimming",
128            Self::NotPrimaryFrPair => "raw_reads_rejected_for_not_primary_fr_pair",
129            Self::R1R2OverlapTooShort => "raw_reads_rejected_for_r1_r2_overlap_too_short",
130            Self::IndelErrorBetweenStrands => "raw_reads_rejected_for_indel_error_between_strands",
131            Self::HighDuplexDisagreement => "raw_reads_rejected_for_high_duplex_disagreement",
132            Self::ClipOverlapFailed => "raw_reads_rejected_for_clip_overlap_failed",
133        }
134    }
135
136    /// Returns a short description for key-value metrics output.
137    #[must_use]
138    pub fn kv_description(&self) -> &'static str {
139        match self {
140            Self::InsufficientSupport => "Insufficient reads to generate a consensus",
141            Self::MinorityAlignment => "Reads has a different, and minority, set of indels",
142            Self::InsufficientStrandSupport => "Insufficient strand support for consensus",
143            Self::LowBaseQuality => "Low base quality",
144            Self::ExcessiveNBases => "Excessive N bases in read",
145            Self::NoValidAlignment => "No valid alignment found",
146            Self::LowMappingQuality => "Low mapping quality",
147            Self::NBasesInUmi => "N bases in UMI sequence",
148            Self::MissingUmi => "Read lacks required UMI tag",
149            Self::NotPassingFilter => "Read did not pass vendor filter",
150            Self::LowMeanQuality => "Low mean base quality",
151            Self::InsufficientMinDepth => "Insufficient minimum read depth",
152            Self::ExcessiveErrorRate => "Excessive error rate",
153            Self::UmiTooShort => "UMI sequence too short",
154            // fgbio's exact title-cased string for `single_strand_only`, matched for parity.
155            Self::SameStrandOnly => "Only Generating One Strand of Duplex Consensus",
156            Self::NonPairedReads => "Unpaired/fragment reads not supported by Duplex caller",
157            Self::DuplicateUmi => "Potential collision between independent duplex molecules",
158            Self::OrphanConsensus => "Only one of R1 or R2 consensus generated",
159            Self::ZeroBasesPostTrimming => "Read or mate had zero bases post trimming",
160            Self::NotPrimaryFrPair => "Template did not have a single primary FR pair of reads",
161            Self::R1R2OverlapTooShort => "Overlap between R1s and R2s too short for CODEC calling",
162            Self::IndelErrorBetweenStrands => "Indel error between top/bottom strands",
163            Self::HighDuplexDisagreement => "Too many errors between top/bottoms strands",
164            Self::ClipOverlapFailed => "See https://github.com/fulcrumgenomics/fgbio/issues/1090",
165        }
166    }
167}
168
169impl fmt::Display for RejectionReason {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        write!(f, "{}", self.description())
172    }
173}
174
175/// Formats a count with thousands separators.
176///
177/// # Examples
178///
179/// ```
180/// use fgumi_metrics::rejection::format_count;
181///
182/// assert_eq!(format_count(1234567), "1,234,567");
183/// assert_eq!(format_count(123), "123");
184/// ```
185#[must_use]
186pub fn format_count(n: u64) -> String {
187    let s = n.to_string();
188    let bytes = s.as_bytes();
189    let len = bytes.len();
190    let num_commas = if len > 3 { (len - 1) / 3 } else { 0 };
191    let mut result = String::with_capacity(len + num_commas);
192    for (i, &byte) in bytes.iter().enumerate() {
193        if i > 0 && (len - i).is_multiple_of(3) {
194            result.push(',');
195        }
196        result.push(byte as char);
197    }
198    result
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_rejection_reason_description() {
207        assert!(RejectionReason::LowBaseQuality.description().contains("quality"));
208        assert!(RejectionReason::InsufficientSupport.description().contains("Insufficient"));
209        // Matches fgbio's exact (grammatically-odd) wording for metric parity.
210        assert_eq!(
211            RejectionReason::MinorityAlignment.to_string(),
212            "Reads has a different, and minority, set of indels"
213        );
214    }
215
216    use strum::IntoEnumIterator;
217
218    /// The four codec-only reasons carry fgbio-verbatim strings (`UmiConsensusCaller.scala:84-87`),
219    /// including the deliberate "top/bottoms" typo and the issue-URL description. Pin the exact
220    /// `description()`, `kv_description()`, and `tsv_key()` so a well-meaning edit can't silently
221    /// break fgbio parity.
222    #[rstest::rstest]
223    #[case::r1_r2_overlap_too_short(
224        RejectionReason::R1R2OverlapTooShort,
225        "Overlap between R1s and R2s too short for CODEC calling",
226        "raw_reads_rejected_for_r1_r2_overlap_too_short"
227    )]
228    #[case::indel_error_between_strands(
229        RejectionReason::IndelErrorBetweenStrands,
230        "Indel error between top/bottom strands",
231        "raw_reads_rejected_for_indel_error_between_strands"
232    )]
233    #[case::high_duplex_disagreement(
234        RejectionReason::HighDuplexDisagreement,
235        "Too many errors between top/bottoms strands",
236        "raw_reads_rejected_for_high_duplex_disagreement"
237    )]
238    #[case::clip_overlap_failed(
239        RejectionReason::ClipOverlapFailed,
240        "See https://github.com/fulcrumgenomics/fgbio/issues/1090",
241        "raw_reads_rejected_for_clip_overlap_failed"
242    )]
243    fn test_codec_reason_strings_match_fgbio(
244        #[case] reason: RejectionReason,
245        #[case] expected_description: &str,
246        #[case] expected_tsv_key: &str,
247    ) {
248        // fgbio uses one description string per reason for both the human-readable and KV forms.
249        assert_eq!(reason.description(), expected_description);
250        assert_eq!(reason.kv_description(), expected_description);
251        assert_eq!(reason.tsv_key(), expected_tsv_key);
252    }
253
254    #[test]
255    fn test_tsv_key_prefix() {
256        // `RejectionReason::iter()` (via `EnumIter`) covers every variant automatically, so a
257        // newly added reason is exercised here without updating a hand-maintained list.
258        for reason in RejectionReason::iter() {
259            assert!(
260                reason.tsv_key().starts_with("raw_reads_rejected_for_"),
261                "tsv_key for {:?} does not have expected prefix: {}",
262                reason,
263                reason.tsv_key()
264            );
265        }
266    }
267
268    #[test]
269    fn test_duplex_row_keys_match_fgbio() {
270        // R2-MET-05 / R2-UCC-01: the duplex/codec rejection rows carry fgbio's exact keys.
271        assert_eq!(
272            RejectionReason::NonPairedReads.tsv_key(),
273            "raw_reads_rejected_for_non_paired_reads"
274        );
275        assert_eq!(
276            RejectionReason::SameStrandOnly.tsv_key(),
277            "raw_reads_rejected_for_single_strand_only"
278        );
279        assert_eq!(
280            RejectionReason::DuplicateUmi.tsv_key(),
281            "raw_reads_rejected_for_potential_umi_collision"
282        );
283    }
284
285    #[test]
286    fn test_kv_description_non_empty() {
287        for reason in RejectionReason::iter() {
288            assert!(!reason.kv_description().is_empty(), "kv_description for {reason:?} is empty");
289        }
290    }
291
292    /// Pin the exact fgbio-parity `tsv_key` + `kv_description` for the two reasons whose strings
293    /// are deliberately matched to fgbio (see the `for parity` comments above). A non-empty check
294    /// would let these silently drift; exact equality is what keeps them aligned with fgbio.
295    #[test]
296    fn test_kv_description_matches_fgbio_verbatim() {
297        assert_eq!(
298            RejectionReason::MinorityAlignment.tsv_key(),
299            "raw_reads_rejected_for_minority_alignment"
300        );
301        assert_eq!(
302            RejectionReason::MinorityAlignment.kv_description(),
303            "Reads has a different, and minority, set of indels"
304        );
305
306        assert_eq!(
307            RejectionReason::SameStrandOnly.tsv_key(),
308            "raw_reads_rejected_for_single_strand_only"
309        );
310        assert_eq!(
311            RejectionReason::SameStrandOnly.kv_description(),
312            "Only Generating One Strand of Duplex Consensus"
313        );
314    }
315
316    #[test]
317    fn test_format_count() {
318        assert_eq!(format_count(0), "0");
319        assert_eq!(format_count(123), "123");
320        assert_eq!(format_count(1234), "1,234");
321        assert_eq!(format_count(1_234_567), "1,234,567");
322        assert_eq!(format_count(1_000_000_000), "1,000,000,000");
323    }
324}