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}