Skip to main content

salmon_model/
libdetect.rs

1//! Automatic library-type detection.
2//!
3//! # What is being detected, and why
4//!
5//! The library type (see [`salmon_core::libtype`]) says how reads relate to the
6//! RNA they came from: which way the mates point, and whether the protocol
7//! preserved strand information. Getting it wrong throws away good mappings or
8//! keeps bad ones, and it is a detail users routinely do not know for a public
9//! dataset. So salmon can work it out from the data: `-l A`.
10//!
11//! The idea is simple. Map a prefix of the reads with *no* strand filter, and
12//! tally what orientation and strand each confident mapping actually had. A truly
13//! unstranded library produces both strands about equally; a stranded one is
14//! lopsided. Count ratios then name the format.
15//!
16//! Port of salmon's `LibraryTypeDetector`
17//! (`include/.../model/LibraryTypeDetector.hpp`). During the first reads of a
18//! run, the observed [`LibraryFormat`] of each confidently mapped fragment is
19//! tallied; once enough samples are seen, the most likely orientation and
20//! strandedness are inferred from the count ratios using salmon's 30%/70%
21//! thresholds.
22
23use salmon_core::{LibraryFormat, ReadOrientation, ReadStrandedness, ReadType};
24use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, AtomicU8, Ordering};
25
26/// Default number of samples to collect before guessing (matches salmon).
27///
28/// Large enough that the ratios are stable, small enough to be a negligible
29/// prefix of a real run.
30pub const DEFAULT_SAMPLES_NEEDED: i64 = 50_000;
31
32/// Sentinel for `resolved` meaning "not yet locked in" (no valid format has this
33/// id; `MAX_FORMAT_ID` is 11).
34///
35/// A sentinel rather than a separate flag, so that "is it resolved, and to what?"
36/// is a single atomic read and the two can never disagree.
37const UNSET_FORMAT: u8 = 0xFF;
38
39/// Accumulates observed library formats and infers the most likely type.
40///
41/// Every field is atomic and every method takes `&self`: all the mapping threads
42/// feed one shared detector while they work.
43#[derive(Debug)]
44pub struct LibraryTypeDetector {
45    /// still sampling (gates [`add_sample`]); cleared once the format locks in
46    active: AtomicBool,
47    read_type: ReadType,
48    /// counts *down* to zero; negative means the budget is spent
49    samples_needed: AtomicI64,
50    /// one counter per library format id
51    counts: Vec<AtomicU64>,
52    /// the locked-in format id once detection completes, else [`UNSET_FORMAT`]
53    resolved: AtomicU8,
54}
55
56impl LibraryTypeDetector {
57    /// A detector for single- or paired-end input, with a fresh sample budget.
58    pub fn new(read_type: ReadType) -> Self {
59        let counts = (0..=LibraryFormat::MAX_FORMAT_ID)
60            .map(|_| AtomicU64::new(0))
61            .collect();
62        Self {
63            active: AtomicBool::new(true),
64            read_type,
65            samples_needed: AtomicI64::new(DEFAULT_SAMPLES_NEEDED),
66            counts,
67            resolved: AtomicU8::new(UNSET_FORMAT),
68        }
69    }
70
71    /// Whether the detector is still collecting samples.
72    pub fn is_active(&self) -> bool {
73        self.active.load(Ordering::Relaxed)
74    }
75
76    /// True once enough samples have been collected to guess.
77    pub fn can_guess(&self) -> bool {
78        self.samples_needed.load(Ordering::Relaxed) <= 0
79    }
80
81    /// Record one confidently mapped fragment's observed format. Only formats
82    /// matching the detector's read type are counted, and only until the sample
83    /// budget is exhausted. Thread-safe.
84    ///
85    /// "Confidently mapped" matters: an ambiguous mapping's orientation is not
86    /// evidence about the protocol, so the caller filters before calling.
87    pub fn add_sample(&self, f: LibraryFormat) {
88        if f.read_type == self.read_type && self.samples_needed.load(Ordering::Relaxed) >= 0 {
89            self.counts[f.format_id() as usize].fetch_add(1, Ordering::Relaxed);
90            self.samples_needed.fetch_sub(1, Ordering::Relaxed);
91        }
92    }
93
94    /// Mid-run resolution (salmon's prefix-detect-then-apply): once enough
95    /// samples have been collected ([`Self::can_guess`]), infer and **lock in** the
96    /// library format (one writer wins the CAS), stop sampling, and return it;
97    /// idempotent thereafter. Returns `None` while still sampling. The caller
98    /// applies the returned format as a strand-compatibility filter for the rest
99    /// of the run.
100    ///
101    /// The compare-and-swap is what makes this safe under concurrency: several
102    /// threads may notice the budget is spent at the same moment and infer
103    /// (possibly slightly different) formats from a racing snapshot of the
104    /// counts, but exactly one store succeeds and every thread then uses that
105    /// same answer.
106    pub fn resolved_format(&self) -> Option<LibraryFormat> {
107        // `Acquire`/`Release` here, unlike the `Relaxed` counters: this value
108        // publishes a decision other threads act on, so the ordering must be
109        // strong enough that they see it consistently.
110        let r = self.resolved.load(Ordering::Acquire);
111        if r != UNSET_FORMAT {
112            return Some(LibraryFormat::from_format_id(r));
113        }
114        if !self.can_guess() {
115            return None;
116        }
117        let f = self.infer_format();
118        match self.resolved.compare_exchange(
119            UNSET_FORMAT,
120            f.format_id(),
121            Ordering::AcqRel,
122            Ordering::Acquire,
123        ) {
124            Ok(_) => {
125                self.active.store(false, Ordering::Release);
126                Some(f)
127            }
128            // Another thread locked in first; use its result.
129            Err(existing) => Some(LibraryFormat::from_format_id(existing)),
130        }
131    }
132
133    /// The final library format to report at end of run: the locked-in format if
134    /// resolution happened mid-run, else inferred from whatever samples were
135    /// collected (recorded so repeat calls agree). Always returns a format.
136    ///
137    /// Needed because a small input may finish before the sample budget is spent;
138    /// there is still a best guess to report, it just never became a filter.
139    pub fn final_format(&self) -> LibraryFormat {
140        let r = self.resolved.load(Ordering::Acquire);
141        if r != UNSET_FORMAT {
142            return LibraryFormat::from_format_id(r);
143        }
144        let f = self.infer_format();
145        // Record it, ignoring the outcome: if someone else won the race their
146        // value is equally valid, and the reload below picks up whichever stuck.
147        let _ = self.resolved.compare_exchange(
148            UNSET_FORMAT,
149            f.format_id(),
150            Ordering::AcqRel,
151            Ordering::Acquire,
152        );
153        LibraryFormat::from_format_id(self.resolved.load(Ordering::Acquire))
154    }
155
156    /// Pure inference of the most likely library format from the accumulated
157    /// counts (no state change). Falls back to inward/unstranded when there are
158    /// no usable samples.
159    fn infer_format(&self) -> LibraryFormat {
160        let counts: Vec<u64> = self
161            .counts
162            .iter()
163            .map(|c| c.load(Ordering::Relaxed))
164            .collect();
165        infer_format_from_counts(&counts, self.read_type)
166    }
167}
168
169/// Pure inference of the most likely library format from per-format counts
170/// (indexed by [`LibraryFormat::format_id`]), applying salmon's orientation and
171/// strandedness thresholds. Order-independent — used both by the prefix-sampling
172/// [`LibraryTypeDetector`] and by RAD auto-detection, which tallies *all* unique
173/// fragments rather than a thread-order-dependent prefix. Falls back to
174/// inward/unstranded when there are no usable samples.
175///
176/// Keeping the decision rule in a free function, separate from the concurrent
177/// accumulator, is what lets the deterministic RAD path share it exactly.
178pub fn infer_format_from_counts(counts: &[u64], read_type: ReadType) -> LibraryFormat {
179    let count = |id: u8| counts[id as usize];
180
181    match read_type {
182        ReadType::SingleEnd => {
183            // Only strandedness is in question; a single read has no relative
184            // orientation to measure.
185            let mut nf = 0u64;
186            let mut nr = 0u64;
187            for id in 0..=LibraryFormat::MAX_FORMAT_ID {
188                let f = LibraryFormat::from_format_id(id);
189                let c = count(id);
190                nf += if f.strandedness == ReadStrandedness::S {
191                    c
192                } else {
193                    0
194                };
195                nr += if f.strandedness == ReadStrandedness::A {
196                    c
197                } else {
198                    0
199                };
200            }
201            let strandedness = if nf + nr == 0 {
202                // No usable evidence: the permissive answer, which rejects nothing.
203                ReadStrandedness::U
204            } else {
205                // Single-end uses the matching (S/A) encoding, like a
206                // paired "same"-orientation library.
207                strandedness_from_fw_ratio(nf as f64 / (nf + nr) as f64, true)
208            };
209            LibraryFormat::new(ReadType::SingleEnd, ReadOrientation::None, strandedness)
210        }
211        ReadType::PairedEnd => {
212            // Two independent questions, tallied in one pass: which orientation
213            // dominates, and how lopsided the strands are.
214            let (mut nsf, mut nsr) = (0u64, 0u64);
215            let (mut ninward, mut noutward, mut nsame) = (0u64, 0u64, 0u64);
216            for id in 0..=LibraryFormat::MAX_FORMAT_ID {
217                let f = LibraryFormat::from_format_id(id);
218                let c = count(id);
219                // Both encodings of "read 1 forward" count as forward evidence:
220                // `S` for same-orientation libraries, `SA` for opposed ones.
221                nsf += matches!(f.strandedness, ReadStrandedness::S | ReadStrandedness::SA)
222                    .then_some(c)
223                    .unwrap_or(0);
224                nsr += matches!(f.strandedness, ReadStrandedness::A | ReadStrandedness::AS)
225                    .then_some(c)
226                    .unwrap_or(0);
227                match f.orientation {
228                    ReadOrientation::Toward => ninward += c,
229                    ReadOrientation::Away => noutward += c,
230                    ReadOrientation::Same => nsame += c,
231                    ReadOrientation::None => {}
232                }
233            }
234
235            let num_orient = ninward + noutward + nsame;
236            if num_orient > 0 && (nsf + nsr) > 0 {
237                let ratio_in = ninward as f64 / num_orient as f64;
238                let ratio_out = noutward as f64 / num_orient as f64;
239                let ratio_same = nsame as f64 / num_orient as f64;
240
241                // Orientation is decided by plurality, not a threshold: a protocol
242                // produces one geometry, and the others are noise.
243                let (orientation, same) = if ratio_in >= ratio_out && ratio_in >= ratio_same {
244                    (ReadOrientation::Toward, false)
245                } else if ratio_out >= ratio_in && ratio_out >= ratio_same {
246                    (ReadOrientation::Away, false)
247                } else {
248                    (ReadOrientation::Same, true)
249                };
250
251                let ratio_fw = nsf as f64 / (nsf + nsr) as f64;
252                let strandedness = strandedness_from_fw_ratio(ratio_fw, same);
253                LibraryFormat::new(ReadType::PairedEnd, orientation, strandedness)
254            } else {
255                // Nothing usable observed: fall back to `IU`, the most permissive
256                // paired format.
257                LibraryFormat::new(
258                    ReadType::PairedEnd,
259                    ReadOrientation::Toward,
260                    ReadStrandedness::U,
261                )
262            }
263        }
264    }
265}
266
267/// Map a forward-strand fraction to a strandedness using salmon's 30%/70%
268/// thresholds. `same` selects between the matching (S/A) and opposite (SA/AS)
269/// stranded encodings for paired-end "same"-orientation libraries; for
270/// single-end pass `false`.
271///
272/// The wide unstranded band is deliberate: a truly unstranded library sits at
273/// 50%, and real stranded protocols are rarely below 90%, so anything in the
274/// middle is far more likely to be unstranded-with-noise than a weak protocol.
275/// Guessing "stranded" wrongly discards half the data, so the rule errs toward
276/// the permissive answer.
277fn strandedness_from_fw_ratio(ratio_fw: f64, same: bool) -> ReadStrandedness {
278    if ratio_fw < 0.3 {
279        if same {
280            ReadStrandedness::A
281        } else {
282            ReadStrandedness::AS
283        }
284    } else if ratio_fw < 0.7 {
285        ReadStrandedness::U
286    } else if same {
287        ReadStrandedness::S
288    } else {
289        ReadStrandedness::SA
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    /// A 90/10 split is well past the 70% threshold, so the sense format wins —
298    /// and repeat calls must agree.
299    #[test]
300    fn single_end_detects_sense() {
301        let d = LibraryTypeDetector::new(ReadType::SingleEnd);
302        let sf = LibraryFormat::parse("SF").unwrap();
303        let sr = LibraryFormat::parse("SR").unwrap();
304        for _ in 0..90 {
305            d.add_sample(sf);
306        }
307        for _ in 0..10 {
308            d.add_sample(sr);
309        }
310        assert_eq!(d.infer_format().canonical(), "SF");
311        // final_format records and returns the same result idempotently
312        assert_eq!(d.final_format().canonical(), "SF");
313        assert_eq!(d.final_format().canonical(), "SF");
314    }
315
316    /// An even split must land in the unstranded band, not be forced to a side.
317    #[test]
318    fn single_end_detects_unstranded() {
319        let d = LibraryTypeDetector::new(ReadType::SingleEnd);
320        let sf = LibraryFormat::parse("SF").unwrap();
321        let sr = LibraryFormat::parse("SR").unwrap();
322        for _ in 0..50 {
323            d.add_sample(sf);
324            d.add_sample(sr);
325        }
326        assert_eq!(d.infer_format().canonical(), "U");
327    }
328
329    /// Paired-end detection has to get orientation and strandedness right at once.
330    #[test]
331    fn paired_end_detects_isr() {
332        let d = LibraryTypeDetector::new(ReadType::PairedEnd);
333        let isr = LibraryFormat::parse("ISR").unwrap();
334        for _ in 0..100 {
335            d.add_sample(isr);
336        }
337        // ISR: inward + antisense -> toward + AS
338        assert_eq!(d.infer_format().canonical(), "ISR");
339    }
340
341    /// The two axes are independent: balanced strands with a consistent geometry
342    /// must give unstranded-inward, not one of the stranded inward formats.
343    #[test]
344    fn paired_end_detects_iu() {
345        let d = LibraryTypeDetector::new(ReadType::PairedEnd);
346        let isf = LibraryFormat::parse("ISF").unwrap();
347        let isr = LibraryFormat::parse("ISR").unwrap();
348        for _ in 0..50 {
349            d.add_sample(isf);
350            d.add_sample(isr);
351        }
352        // balanced strandedness -> unstranded, inward -> IU
353        assert_eq!(d.infer_format().canonical(), "IU");
354    }
355
356    /// The mid-run protocol end to end: no answer before the budget is spent, a
357    /// locked-in answer after, sampling stopped, and idempotent thereafter.
358    #[test]
359    fn resolved_format_locks_in_after_prefix() {
360        let d = LibraryTypeDetector::new(ReadType::PairedEnd);
361        let isr = LibraryFormat::parse("ISR").unwrap();
362        // Before the sample budget is consumed: no resolution yet, so the caller
363        // applies no filter, and the detector keeps sampling.
364        assert!(d.resolved_format().is_none());
365        assert!(d.is_active());
366        // Feed the full prefix budget.
367        for _ in 0..DEFAULT_SAMPLES_NEEDED {
368            d.add_sample(isr);
369        }
370        assert!(d.can_guess());
371        // Now it locks in to the inferred type, stops sampling, and is idempotent.
372        assert_eq!(d.resolved_format().unwrap().canonical(), "ISR");
373        assert!(!d.is_active());
374        assert_eq!(d.resolved_format().unwrap().canonical(), "ISR");
375        assert_eq!(d.final_format().canonical(), "ISR");
376    }
377
378    /// A small input never reaches the budget, but the run must still report a
379    /// library type at the end.
380    #[test]
381    fn final_format_without_lockin_infers_from_partial() {
382        // Fewer than the budget: never locks in mid-run, but end-of-run reporting
383        // still returns a best-guess format from the partial samples.
384        let d = LibraryTypeDetector::new(ReadType::PairedEnd);
385        let isf = LibraryFormat::parse("ISF").unwrap();
386        for _ in 0..100 {
387            d.add_sample(isf);
388        }
389        assert!(d.resolved_format().is_none()); // not enough to lock in mid-run
390        assert_eq!(d.final_format().canonical(), "ISF");
391    }
392
393    /// Sampling must genuinely stop, so detection stays a bounded prefix cost
394    /// rather than running for the whole input.
395    #[test]
396    fn sample_budget_is_respected() {
397        let d = LibraryTypeDetector::new(ReadType::SingleEnd);
398        assert!(!d.can_guess());
399        let sf = LibraryFormat::parse("SF").unwrap();
400        // exhaust the budget
401        let mut n = DEFAULT_SAMPLES_NEEDED + 5;
402        while n > 0 {
403            d.add_sample(sf);
404            n -= 1;
405        }
406        assert!(d.can_guess());
407    }
408}