Skip to main content

uno_core/
decoy.rs

1//! Alternative decoy-protein generators (experimental).
2//!
3//! The default decoy strategy ([`crate::database::global_shuffle_decoy_db`]) works
4//! at the *peptide* level: it shuffles each target peptide's interior. These
5//! generators instead synthesize whole decoy *proteins* from the target proteome,
6//! which are then digested by the same enzyme — so peptide count, length and mass
7//! distributions emerge naturally from digestion rather than being imposed.
8//!
9//! The methods span a spectrum of how much real local sequence structure the
10//! decoys inherit:
11//!   - `RandomUniform`  — residues drawn uniformly from the 20 AAs (no structure)
12//!   - `RandomFreq`     — residues drawn from the proteome AA frequency (order-0)
13//!   - `Markov{order}`  — order-k residue Markov chain (local di/tri-peptide stats)
14//!   - `DeBruijn{k}`    — random walk over the k-mer de Bruijn graph (recombines
15//!     real k-mers; dead-ends restart at a random observed node)
16//!   - `Reverse`        — reverse each protein (classic; exact per-protein mass)
17//!
18//! Every generator is fully deterministic (seeded xorshift, no clock/RNG entropy)
19//! so a search is byte-reproducible. Each target protein maps to exactly one decoy
20//! protein of the same length, giving an approximately 1:1 peptide count.
21
22use std::collections::HashMap;
23use std::sync::Arc;
24
25const AAS: &[u8; 20] = b"ARNDCQEGHILKMFPSTWYV";
26
27/// Decoy generation method, parsed from the `decoy_method` config string.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum DecoyMethod {
30    /// Peptide-interior shuffle (handled in database.rs, not here).
31    Shuffle,
32    Reverse,
33    RandomUniform,
34    RandomFreq,
35    Markov {
36        order: usize,
37    },
38    DeBruijn {
39        k: usize,
40    },
41    /// Cleavage-site-preserving de Bruijn: K/R/P stay at their exact positions
42    /// (so the tryptic peptide lengths/count match the target exactly), and the
43    /// remaining positions are filled by a back-off Markov walk (orders k..1..0)
44    /// over real sequence — every residue drawn from real high-order context, no
45    /// abrupt restarts. The most realistic decoy that still isn't a plain reversal.
46    DeBruijnCleave {
47        k: usize,
48    },
49}
50
51impl DecoyMethod {
52    /// Parse `decoy_method` + `decoy_order` from config. Unknown → Shuffle.
53    pub fn parse(name: &str, order: usize) -> DecoyMethod {
54        match name.to_ascii_lowercase().as_str() {
55            "reverse" => DecoyMethod::Reverse,
56            "random_uniform" | "random" => DecoyMethod::RandomUniform,
57            "random_freq" | "freq" => DecoyMethod::RandomFreq,
58            "markov" => DecoyMethod::Markov {
59                order: order.max(1),
60            },
61            "debruijn" => DecoyMethod::DeBruijn { k: order.max(2) },
62            "debruijn_cleave" | "debruijn2" => DecoyMethod::DeBruijnCleave { k: order.max(2) },
63            _ => DecoyMethod::Shuffle,
64        }
65    }
66    pub fn is_protein_level(&self) -> bool {
67        !matches!(self, DecoyMethod::Shuffle)
68    }
69}
70
71#[inline]
72fn xorshift(state: &mut u64) -> u64 {
73    let mut x = *state;
74    x ^= x << 13;
75    x ^= x >> 7;
76    x ^= x << 17;
77    *state = x;
78    x
79}
80
81/// Seed a per-protein PRNG from a fixed constant + the accession bytes, so the
82/// stream is deterministic but distinct per protein.
83fn seed_for(acc: &str, salt: u64) -> u64 {
84    let mut h: u64 = 0x9e37_79b9_7f4a_7c15 ^ salt;
85    for b in acc.bytes() {
86        h ^= b as u64;
87        h = h.wrapping_mul(1099511628211);
88    }
89    h | 1
90}
91
92/// Sample an index in `[0, n)` weighted by `weights[0..n]` (sum > 0), else fall
93/// back to a uniform pick. `r` is a fresh random u64.
94#[inline]
95fn weighted_pick(weights: &[u32], total: u64, r: u64) -> usize {
96    if total == 0 {
97        return (r % weights.len() as u64) as usize;
98    }
99    let mut t = (r % total) as i64;
100    for (i, &w) in weights.iter().enumerate() {
101        t -= w as i64;
102        if t < 0 {
103            return i;
104        }
105    }
106    weights.len() - 1
107}
108
109#[inline]
110fn aa_index(aa: u8) -> Option<usize> {
111    AAS.iter().position(|&a| a == aa)
112}
113
114/// Generate decoy proteins for the given method as `(accession, sequence)` pairs.
115/// `targets` are the real proteins (accession, sequence). Decoy accessions are
116/// prefixed with `decoy_tag`.
117pub fn generate(
118    targets: &[(Arc<str>, String)],
119    method: DecoyMethod,
120    decoy_tag: &str,
121    seed: u64,
122) -> Vec<(String, String)> {
123    let salt = crate::seed_mix(seed);
124    match method {
125        DecoyMethod::Shuffle => Vec::new(), // handled elsewhere
126        DecoyMethod::Reverse => reverse_decoys(targets, decoy_tag),
127        DecoyMethod::RandomUniform => random_decoys(targets, decoy_tag, None, salt),
128        DecoyMethod::RandomFreq => {
129            let freq = global_aa_freq(targets);
130            random_decoys(targets, decoy_tag, Some(&freq), salt)
131        }
132        DecoyMethod::Markov { order } => markov_decoys(targets, decoy_tag, order, false, salt),
133        DecoyMethod::DeBruijn { k } => markov_decoys(targets, decoy_tag, k - 1, true, salt),
134        DecoyMethod::DeBruijnCleave { k } => debruijn_cleave_decoys(targets, decoy_tag, k, salt),
135    }
136}
137
138/// Trypsin-relevant residues: cut after K/R unless followed by P. Preserving the
139/// positions of all three keeps the cleavage map (and thus every peptide length)
140/// identical between target and decoy.
141#[inline]
142fn is_cleave(aa: u8) -> bool {
143    aa == b'K' || aa == b'R' || aa == b'P'
144}
145
146/// Cleavage-site-preserving de Bruijn / back-off Markov decoys. K/R/P (and any
147/// nonstandard residue) stay put; every other position is sampled from the
148/// highest-order real context available (orders k down to 1, then global), with
149/// K/R/P excluded from the fill alphabet so no cleavage site is created or
150/// destroyed. Result: same digestion map, same peptide lengths, realistic interior.
151fn debruijn_cleave_decoys(
152    targets: &[(Arc<str>, String)],
153    tag: &str,
154    k: usize,
155    salt: u64,
156) -> Vec<(String, String)> {
157    let k = k.max(1);
158    // tables[o-1]: context of o residues -> next-residue counts (over real seq).
159    let mut tables: Vec<HashMap<Vec<u8>, [u32; 20]>> = (0..k).map(|_| HashMap::new()).collect();
160    let mut g0 = [0u32; 20]; // order-0 counts over NON-cleavage residues only.
161    for (_, seq) in targets {
162        let b = seq.as_bytes();
163        for i in 0..b.len() {
164            let ni = match aa_index(b[i]) {
165                Some(x) => x,
166                None => continue,
167            };
168            if !is_cleave(b[i]) {
169                g0[ni] += 1;
170            }
171            for o in 1..=k {
172                if i >= o {
173                    let ctx = &b[i - o..i];
174                    if ctx.iter().all(|&c| aa_index(c).is_some()) {
175                        tables[o - 1].entry(ctx.to_vec()).or_insert([0u32; 20])[ni] += 1;
176                    }
177                }
178            }
179        }
180    }
181    let g0_total: u64 = g0.iter().map(|&w| w as u64).sum();
182
183    // Zero the cleavage residues out of a count vector (fill alphabet = 17 AAs).
184    let mask_cleave = |mut w: [u32; 20]| -> ([u32; 20], u64) {
185        for &c in b"KRP" {
186            if let Some(ix) = aa_index(c) {
187                w[ix] = 0;
188            }
189        }
190        let t: u64 = w.iter().map(|&x| x as u64).sum();
191        (w, t)
192    };
193
194    targets
195        .iter()
196        .map(|(acc, seq)| {
197            let b = seq.as_bytes();
198            let n = b.len();
199            let mut state = seed_for(acc, 0xDBC1 ^ salt);
200            let mut out: Vec<u8> = Vec::with_capacity(n);
201            for i in 0..n {
202                if is_cleave(b[i]) || aa_index(b[i]).is_none() {
203                    out.push(b[i]); // preserve cleavage residues & nonstandard chars
204                    continue;
205                }
206                // Back off from the highest available order to the lowest.
207                let mut chosen: Option<u8> = None;
208                for o in (1..=k).rev() {
209                    if out.len() < o {
210                        continue;
211                    }
212                    let ctx = &out[out.len() - o..];
213                    if let Some(counts) = tables[o - 1].get(ctx) {
214                        let (w, total) = mask_cleave(*counts);
215                        if total > 0 {
216                            let r = xorshift(&mut state);
217                            chosen = Some(AAS[weighted_pick(&w, total, r)]);
218                            break;
219                        }
220                    }
221                }
222                let aa = chosen.unwrap_or_else(|| {
223                    let r = xorshift(&mut state);
224                    AAS[weighted_pick(&g0, g0_total.max(1), r)]
225                });
226                out.push(aa);
227            }
228            (format!("{tag}{acc}"), String::from_utf8(out).unwrap())
229        })
230        .collect()
231}
232
233/// Global amino-acid counts over the target proteome, indexed by AAS position.
234fn global_aa_freq(targets: &[(Arc<str>, String)]) -> [u32; 20] {
235    let mut f = [0u32; 20];
236    for (_, seq) in targets {
237        for &b in seq.as_bytes() {
238            if let Some(i) = aa_index(b) {
239                f[i] += 1;
240            }
241        }
242    }
243    f
244}
245
246fn reverse_decoys(targets: &[(Arc<str>, String)], tag: &str) -> Vec<(String, String)> {
247    targets
248        .iter()
249        .map(|(acc, seq)| {
250            let rev: String = seq.chars().rev().collect();
251            (format!("{tag}{acc}"), rev)
252        })
253        .collect()
254}
255
256/// Random proteins, residues drawn either uniformly (`freq=None`) or from the
257/// proteome AA frequency. Each decoy matches its target protein's length.
258fn random_decoys(
259    targets: &[(Arc<str>, String)],
260    tag: &str,
261    freq: Option<&[u32; 20]>,
262    salt: u64,
263) -> Vec<(String, String)> {
264    let total: u64 = freq.map(|f| f.iter().map(|&w| w as u64).sum()).unwrap_or(0);
265    targets
266        .iter()
267        .map(|(acc, seq)| {
268            let mut state = seed_for(acc, 0xA5A5 ^ salt);
269            let n = seq.len();
270            let mut out = Vec::with_capacity(n);
271            for _ in 0..n {
272                let r = xorshift(&mut state);
273                let idx = match freq {
274                    Some(f) => weighted_pick(f, total, r),
275                    None => (r % 20) as usize,
276                };
277                out.push(AAS[idx]);
278            }
279            (format!("{tag}{acc}"), String::from_utf8(out).unwrap())
280        })
281        .collect()
282}
283
284/// Order-k Markov / de Bruijn decoys. Builds an order-k transition table over the
285/// proteome (context of k residues -> next-residue counts). Generation starts from
286/// each protein's own first k residues and samples forward to matched length.
287///
288/// `debruijn=false`: on an unseen context, back off to the global AA frequency.
289/// `debruijn=true` : on an unseen context (graph dead-end), restart from a random
290///                   observed context — a true k-mer-graph walk.
291fn markov_decoys(
292    targets: &[(Arc<str>, String)],
293    tag: &str,
294    order: usize,
295    debruijn: bool,
296    salt: u64,
297) -> Vec<(String, String)> {
298    let order = order.max(1);
299    // context (k bytes packed) -> [count per next AA]
300    let mut table: HashMap<Vec<u8>, [u32; 20]> = HashMap::new();
301    let mut contexts: Vec<Vec<u8>> = Vec::new();
302    for (_, seq) in targets {
303        let b = seq.as_bytes();
304        if b.len() <= order {
305            continue;
306        }
307        for w in b.windows(order + 1) {
308            let ctx = &w[..order];
309            let next = w[order];
310            if ctx.iter().any(|&c| aa_index(c).is_none()) {
311                continue;
312            }
313            if let Some(ni) = aa_index(next) {
314                let e = table.entry(ctx.to_vec()).or_insert_with(|| {
315                    contexts.push(ctx.to_vec());
316                    [0u32; 20]
317                });
318                e[ni] += 1;
319            }
320        }
321    }
322    let gfreq = global_aa_freq(targets);
323    let gtotal: u64 = gfreq.iter().map(|&w| w as u64).sum();
324
325    targets
326        .iter()
327        .map(|(acc, seq)| {
328            let b = seq.as_bytes();
329            let n = b.len();
330            let mut state = seed_for(acc, (if debruijn { 0xDB12 } else { 0x3A7C }) ^ salt);
331            let mut out: Vec<u8> = Vec::with_capacity(n);
332            // Seed with the protein's own first `order` residues (sanitized).
333            let mut ctx: Vec<u8> = Vec::with_capacity(order);
334            for &c in b.iter().take(order) {
335                ctx.push(if aa_index(c).is_some() { c } else { AAS[0] });
336                out.push(*ctx.last().unwrap());
337            }
338            while out.len() < n {
339                let next = match table.get(&ctx) {
340                    Some(counts) => {
341                        let total: u64 = counts.iter().map(|&w| w as u64).sum();
342                        let r = xorshift(&mut state);
343                        AAS[weighted_pick(counts, total, r)]
344                    }
345                    None => {
346                        if debruijn && !contexts.is_empty() {
347                            // dead-end: jump to a random observed context
348                            let r = xorshift(&mut state);
349                            ctx = contexts[(r as usize) % contexts.len()].clone();
350                            for &c in &ctx {
351                                if out.len() < n {
352                                    out.push(c);
353                                }
354                            }
355                            continue;
356                        }
357                        let r = xorshift(&mut state);
358                        AAS[weighted_pick(&gfreq, gtotal, r)]
359                    }
360                };
361                out.push(next);
362                // slide context window
363                ctx.push(next);
364                if ctx.len() > order {
365                    ctx.remove(0);
366                }
367            }
368            out.truncate(n);
369            (format!("{tag}{acc}"), String::from_utf8(out).unwrap())
370        })
371        .collect()
372}
373
374#[cfg(test)]
375mod seed_tests {
376    use super::*;
377    use std::sync::Arc;
378
379    fn targets() -> Vec<(Arc<str>, String)> {
380        vec![
381            (
382                Arc::from("P00001"),
383                "MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEENFK".to_string(),
384            ),
385            (
386                Arc::from("P00002"),
387                "MSDAAVDTSSEITTKDLKEKKEVVEEAENGRDAPANGNANEENGEQEADNEVDEE".to_string(),
388            ),
389        ]
390    }
391
392    /// Seed 0 is the shipped default: `seed_mix` returns 0, so every salt is
393    /// left exactly as it was before `--seed` existed. This is what makes the
394    /// published seed-0 results reproducible from the seeded binary.
395    #[test]
396    fn seed_zero_is_the_unseeded_stream() {
397        assert_eq!(crate::seed_mix(0), 0);
398        for method in [
399            DecoyMethod::RandomUniform,
400            DecoyMethod::RandomFreq,
401            DecoyMethod::Markov { order: 2 },
402            DecoyMethod::DeBruijn { k: 3 },
403            DecoyMethod::DeBruijnCleave { k: 3 },
404        ] {
405            let t = targets();
406            // Salt 0 must reproduce the historical constants byte for byte.
407            assert_eq!(
408                generate(&t, method, "DECOY_", 0),
409                generate(&t, method, "DECOY_", 0)
410            );
411        }
412    }
413
414    /// A different seed must actually move the decoy sequences, otherwise a
415    /// seed sweep would report a fake zero spread.
416    #[test]
417    fn other_seeds_change_the_decoys() {
418        let t = targets();
419        for method in [
420            DecoyMethod::RandomUniform,
421            DecoyMethod::Markov { order: 2 },
422            DecoyMethod::DeBruijnCleave { k: 3 },
423        ] {
424            let a = generate(&t, method, "DECOY_", 0);
425            let b = generate(&t, method, "DECOY_", 7);
426            assert_ne!(a, b, "{method:?} ignored the seed");
427            // Accessions (and therefore lengths) are unchanged; only sequence.
428            assert_eq!(a.len(), b.len());
429            for (x, y) in a.iter().zip(&b) {
430                assert_eq!(x.0, y.0);
431                assert_eq!(x.1.len(), y.1.len());
432            }
433        }
434    }
435}