Skip to main content

uno_core/
database.rs

1//! Phase 2: in-silico peptide database.
2//!
3//! Digestion and modification expansion come from [`crate::digest`] (a port of
4//! the Sage database builder); decoys are generated here. We keep only the lean subset an MS1 engine needs — stripped sequence, neutral
5//! mass, target/decoy flag, source proteins — recompute the mass in f64 for
6//! precision, and sort by mass for binary-search candidate lookup.
7
8use crate::digest::{self, DigestParams, Fasta, Peptide};
9use crate::mass;
10use anyhow::{Context, Result};
11use std::collections::{HashMap, HashSet};
12use std::path::Path;
13use std::sync::Arc;
14
15/// Digestion + modification configuration. Mirrors Sage's database block.
16#[derive(Debug, Clone, serde::Deserialize)]
17#[serde(default)]
18pub struct DigestConfig {
19    pub missed_cleavages: u8,
20    pub min_len: usize,
21    pub max_len: usize,
22    /// Residues to cleave at (trypsin = "KR").
23    pub cleave_at: String,
24    /// Residues that block cleavage when following a cleavage site ("P").
25    pub restrict: String,
26    pub c_terminal: bool,
27    pub peptide_min_mass: f32,
28    pub peptide_max_mass: f32,
29    /// Fixed modifications, e.g. {"C": 57.02146}.
30    pub static_mods: HashMap<String, f32>,
31    /// Variable modifications, e.g. {"M": [15.9949]}.
32    pub variable_mods: HashMap<String, Vec<f32>>,
33    pub max_variable_mods: usize,
34    pub decoy_tag: String,
35    pub generate_decoys: bool,
36    /// Mass (Da) added to every decoy peptide so decoys are NOT mass twins of
37    /// their targets. A real feature then never competes against its own decoy;
38    /// decoys match only by random mass coincidence, giving an honest MS1 null
39    /// where retention time has full discriminating power. (koth uses 11 Da.)
40    pub decoy_mass_shift: f64,
41    /// Generate decoys by SHUFFLING the peptide interior (same mass as the
42    /// target) instead of reversing + mass-shifting. Shuffled same-mass decoys
43    /// model the random mass-coincidence background honestly (unlike mass-shifted
44    /// decoys, which sample a different mass region and underestimate the false
45    /// rate), while randomized order keeps them from being RT-twins (unlike plain
46    /// reversal). When true, `decoy_mass_shift` is ignored.
47    pub decoy_shuffle: bool,
48    /// Protein-level decoy generator (experimental). When set to anything other
49    /// than "shuffle", whole decoy *proteins* are synthesized from the target
50    /// proteome and digested by the same enzyme, overriding `decoy_shuffle`.
51    /// One of: "shuffle" (default), "reverse", "random_uniform", "random_freq",
52    /// "markov", "debruijn". See [`crate::decoy`].
53    pub decoy_method: String,
54    /// Order k for "markov" / "debruijn" decoy methods (ignored otherwise).
55    pub decoy_order: usize,
56    /// Seed for the decoy shuffle (0 = the shipped default stream; see
57    /// [`crate::seed_mix`]). Set with `uno --seed`.
58    pub decoy_seed: u64,
59}
60
61/// Build the target + decoy database with ms1searchpy-style shuffled decoys.
62///
63/// The interior residues are pooled PER PROTEIN (not globally — see the grouping
64/// below), shuffled, and dealt back out to that protein's decoys, keeping each
65/// peptide's terminal residues. Unlike a per-peptide permutation, this generally
66/// gives each decoy a different composition, and therefore a different mass, so
67/// decoys sample the random mass-coincidence background instead of being
68/// mass-twins of their targets. That is what keeps the per-protein binomial's
69/// random rate p low and the FDR honest. Nothing here enforces non-collision with
70/// a target: decoys are deduplicated against each other only.
71fn global_shuffle_decoy_db(peptides_in: Vec<Peptide>, cfg: &DigestConfig) -> Vec<TheoPeptide> {
72    // Per-residue static modification delta (e.g. C +57.021).
73    let static_mod = |aa: u8| -> f64 {
74        cfg.static_mods
75            .get(&(aa as char).to_string())
76            .copied()
77            .unwrap_or(0.0) as f64
78    };
79    let seq_mass = |seq: &[u8]| -> f64 {
80        let mut m = mass::H2O;
81        for &aa in seq {
82            m += mass::residue(aa).unwrap_or(0.0) + static_mod(aa);
83        }
84        m
85    };
86
87    // Targets only (drop the digest's reversed decoys). Group each target peptide
88    // under its FIRST protein so the interior pool is built PER PROTEIN — this
89    // preserves each organism's residue composition, so a decoy of a human protein
90    // has human-like masses and coincides with features at the same rate an absent
91    // human peptide does. A global pool would average organisms and make the
92    // binomial p under-estimate the in-organism random rate.
93    let mut targets: Vec<TheoPeptide> = Vec::with_capacity(peptides_in.len());
94    // per-protein: accession -> Vec<(seq, mc)>; plus a stable protein order.
95    let mut by_prot: HashMap<Arc<str>, Vec<(Vec<u8>, u8)>> = HashMap::new();
96    let mut prot_order: Vec<Arc<str>> = Vec::new();
97    for p in peptides_in.into_iter().filter(|p| !p.decoy) {
98        let mass = mass::neutral_mass(&p.sequence, &p.modifications, p.nterm, p.cterm)
99            .unwrap_or(p.monoisotopic as f64);
100        let seq_vec = p.sequence.to_vec();
101        let sequence: Arc<str> = Arc::from(std::str::from_utf8(&seq_vec).unwrap_or("").to_owned());
102        targets.push(TheoPeptide {
103            sequence,
104            mass,
105            decoy: false,
106            missed_cleavages: p.missed_cleavages,
107            proteins: p.proteins.clone(),
108        });
109        if let Some(first) = p.proteins.first() {
110            by_prot
111                .entry(first.clone())
112                .or_insert_with(|| {
113                    prot_order.push(first.clone());
114                    Vec::new()
115                })
116                .push((seq_vec, p.missed_cleavages));
117        }
118    }
119    prot_order.sort();
120
121    // For each protein: pool its peptides' interior residues, deterministic
122    // shuffle, deal back out to decoys (keeping terminal residues). Reuse a decoy
123    // per unique target sequence so shared peptides map consistently.
124    let mut shuf_map: HashMap<Vec<u8>, (Vec<u8>, u8)> = HashMap::new();
125    let mut decoys: Vec<TheoPeptide> = Vec::with_capacity(targets.len());
126    let mut decoy_prot: HashMap<Vec<u8>, Vec<Arc<str>>> = HashMap::new();
127    // first, map each unique decoy sequence to the union of proteins it should
128    // carry (mirror of the target peptide->proteins map).
129    for t in &targets {
130        decoy_prot
131            .entry(t.sequence.as_bytes().to_vec())
132            .or_insert_with(|| t.proteins.clone());
133    }
134    for acc in &prot_order {
135        let peps = &by_prot[acc];
136        let mut pool: Vec<u8> = Vec::new();
137        for (seq, _) in peps {
138            if seq.len() > 2 {
139                pool.extend_from_slice(&seq[1..seq.len() - 1]);
140            }
141        }
142        if pool.is_empty() {
143            continue;
144        }
145        let mut h: u64 =
146            0x9e37_79b9_7f4a_7c15 ^ (pool.len() as u64) ^ crate::seed_mix(cfg.decoy_seed);
147        for b in acc.bytes() {
148            h ^= b as u64;
149            h = h.wrapping_mul(1099511628211);
150        }
151        for i in (1..pool.len()).rev() {
152            h ^= h << 13;
153            h ^= h >> 7;
154            h ^= h << 17;
155            pool.swap(i, (h % (i as u64 + 1)) as usize);
156        }
157        let mut cur = 0usize;
158        for (seq, mc) in peps {
159            if seq.len() <= 2 || shuf_map.contains_key(seq) {
160                continue;
161            }
162            let mut d = Vec::with_capacity(seq.len());
163            d.push(seq[0]);
164            for _ in 1..seq.len() - 1 {
165                d.push(pool[cur % pool.len()]);
166                cur += 1;
167            }
168            d.push(seq[seq.len() - 1]);
169            shuf_map.insert(seq.clone(), (d, *mc));
170        }
171    }
172    // Deterministic order: HashMap iteration order is process-random, and decoy
173    // sequence collisions resolve to whichever target is seen first — so iterate
174    // shuf_map sorted by target sequence, or the decoy DB (and every downstream
175    // result) varies run to run.
176    let mut shuf_sorted: Vec<(&Vec<u8>, &(Vec<u8>, u8))> = shuf_map.iter().collect();
177    shuf_sorted.sort_by(|a, b| a.0.cmp(b.0));
178    let mut seen_decoy: HashSet<Vec<u8>> = HashSet::new();
179    for (tseq, (dseq, mc)) in shuf_sorted {
180        if !seen_decoy.insert(dseq.clone()) {
181            continue;
182        }
183        let mass = seq_mass(dseq);
184        let proteins = decoy_prot.get(tseq).cloned().unwrap_or_default();
185        let sequence: Arc<str> = Arc::from(std::str::from_utf8(dseq).unwrap_or("").to_owned());
186        decoys.push(TheoPeptide {
187            sequence,
188            mass,
189            decoy: true,
190            missed_cleavages: *mc,
191            proteins,
192        });
193    }
194
195    targets.extend(decoys);
196    targets
197}
198
199/// Digest a FASTA text with `cfg`. For protein-level decoy methods, whole decoy
200/// proteins are synthesized from the targets, appended tagged "DECOY_", and the
201/// combined set is digested with decoy generation OFF: target peptides come out
202/// decoy=false, DECOY_ peptides decoy=true, and decoy peptides equal to a target
203/// digest are dropped. Returns (peptides, protein_level, decoy tag used).
204fn digest_fasta(contents: &str, cfg: &DigestConfig) -> Result<(Vec<Peptide>, bool, String)> {
205    use crate::decoy::DecoyMethod;
206    let method = DecoyMethod::parse(&cfg.decoy_method, cfg.decoy_order);
207    let protein_level = cfg.generate_decoys && method.is_protein_level();
208    let (fasta, gen_decoys, decoy_tag) = if protein_level {
209        let tfasta = Fasta::parse(contents, "DECOY_", false)?;
210        let decoy_prots = crate::decoy::generate(&tfasta.targets, method, "DECOY_", cfg.decoy_seed);
211        let mut combined = contents.to_string();
212        if !combined.ends_with('\n') {
213            combined.push('\n');
214        }
215        for (acc, seq) in &decoy_prots {
216            combined.push('>');
217            combined.push_str(acc);
218            combined.push('\n');
219            combined.push_str(seq);
220            combined.push('\n');
221        }
222        log::info!(
223            "protein-level decoys ({:?}): {} target proteins -> {} decoy proteins",
224            method,
225            tfasta.targets.len(),
226            decoy_prots.len()
227        );
228        (
229            Fasta::parse(&combined, "DECOY_", false)?,
230            false,
231            "DECOY_".to_string(),
232        )
233    } else {
234        (
235            Fasta::parse(contents, &cfg.decoy_tag, cfg.generate_decoys)?,
236            cfg.generate_decoys,
237            cfg.decoy_tag.clone(),
238        )
239    };
240    let params = DigestParams {
241        missed_cleavages: cfg.missed_cleavages,
242        min_len: cfg.min_len,
243        max_len: cfg.max_len,
244        cleave_at: cfg.cleave_at.clone(),
245        restrict: cfg.restrict.clone(),
246        c_terminal: cfg.c_terminal,
247        peptide_min_mass: cfg.peptide_min_mass,
248        peptide_max_mass: cfg.peptide_max_mass,
249        static_mods: cfg.static_mods.clone(),
250        variable_mods: cfg.variable_mods.clone(),
251        max_variable_mods: cfg.max_variable_mods,
252        generate_decoys: gen_decoys,
253    };
254    Ok((digest::digest(&fasta, &params)?, protein_level, decoy_tag))
255}
256
257impl Default for DigestConfig {
258    fn default() -> Self {
259        let mut static_mods = HashMap::new();
260        static_mods.insert("C".to_string(), 57.021_46);
261        // DirectMS1/ms1searchpy convention: fully-tryptic (mc=0), NO variable mods.
262        // MS1-only cannot localize mods, and oxidized/missed-cleavage variants just
263        // inflate the search space and the random-match background. Opt in via config
264        // if needed.
265        let variable_mods = HashMap::new();
266        DigestConfig {
267            missed_cleavages: 0,
268            min_len: 7,
269            max_len: 50,
270            cleave_at: "KR".to_string(),
271            restrict: "P".to_string(),
272            c_terminal: true,
273            peptide_min_mass: 500.0,
274            peptide_max_mass: 5000.0,
275            static_mods,
276            variable_mods,
277            max_variable_mods: 2,
278            decoy_tag: "rev_".to_string(),
279            generate_decoys: true,
280            decoy_mass_shift: 11.0,
281            decoy_shuffle: true,
282            decoy_method: "shuffle".to_string(),
283            decoy_seed: 0,
284            decoy_order: 2,
285        }
286    }
287}
288
289/// A theoretical peptide candidate. Modform identity is captured by
290/// (`sequence`, `mass`): two variable-mod forms of one sequence differ in mass.
291#[derive(Debug, Clone)]
292pub struct TheoPeptide {
293    /// Stripped amino-acid sequence (reverse-path decoys have their interior reversed).
294    pub sequence: Arc<str>,
295    /// Neutral monoisotopic mass (Da), recomputed in f64.
296    pub mass: f64,
297    pub decoy: bool,
298    pub missed_cleavages: u8,
299    /// Source protein accessions (shared `Arc`s; cheap to clone).
300    pub proteins: Vec<Arc<str>>,
301}
302
303/// Mass-sorted peptide database with binary-search candidate lookup.
304pub struct PeptideDb {
305    /// Sorted ascending by `mass`.
306    pub peptides: Vec<TheoPeptide>,
307    pub n_targets: usize,
308    pub n_decoys: usize,
309}
310
311impl PeptideDb {
312    /// Build the database from a FASTA file using the given digest config.
313    pub fn build(fasta_path: impl AsRef<Path>, cfg: &DigestConfig) -> Result<Self> {
314        let fasta_path = fasta_path.as_ref();
315        let contents = std::fs::read_to_string(fasta_path)
316            .with_context(|| format!("reading fasta {}", fasta_path.display()))?;
317        let (peptides_in, protein_level, decoy_tag) = digest_fasta(&contents, cfg)?;
318        Ok(Self::assemble(peptides_in, cfg, protein_level, &decoy_tag))
319    }
320
321    /// Turn digested peptides into the sorted uno database (decoy handling + f64 masses).
322    fn assemble(
323        peptides_in: Vec<Peptide>,
324        cfg: &DigestConfig,
325        protein_level: bool,
326        decoy_tag: &str,
327    ) -> Self {
328        let mut peptides: Vec<TheoPeptide> =
329            if cfg.decoy_shuffle && cfg.generate_decoys && !protein_level {
330                global_shuffle_decoy_db(peptides_in, cfg)
331            } else {
332                peptides_in
333                    .into_iter()
334                    .map(|p| {
335                        let mut mass =
336                            mass::neutral_mass(&p.sequence, &p.modifications, p.nterm, p.cterm)
337                                .unwrap_or(p.monoisotopic as f64);
338                        // Protein-level decoys carry real digestion masses; only the
339                        // reverse+mass-shift path applies an artificial offset.
340                        if p.decoy && !protein_level {
341                            mass += cfg.decoy_mass_shift;
342                        }
343                        let sequence: Arc<str> =
344                            Arc::from(std::str::from_utf8(&p.sequence).unwrap_or("").to_owned());
345                        // Strip the decoy_tag prefix from decoy protein accessions so they
346                        // read IDENTICALLY to their target counterpart's accession (only
347                        // the `decoy` flag distinguishes them). Sage's own Fasta::parse
348                        // keeps the tag baked into the accession string when reading an
349                        // already-decoy-tagged FASTA (generate_decoys=false); without this,
350                        // a decoy accession can never equal any target accession, so the
351                        // (decoy, accession) pair-competition FDR step never fires for ANY
352                        // protein in the run -- silently inflating the reported q-values
353                        // (measured: 713 nominal @q<=1% vs 167 true on yeast entrapment).
354                        // uno's own decoy_shuffle path already satisfies this by cloning
355                        // the target's Arc<str> directly (see global_shuffle_decoy_db).
356                        let proteins = if p.decoy {
357                            p.proteins
358                                .iter()
359                                .map(|a| {
360                                    a.strip_prefix(decoy_tag)
361                                        .map(Arc::from)
362                                        .unwrap_or_else(|| a.clone())
363                                })
364                                .collect()
365                        } else {
366                            p.proteins
367                        };
368                        TheoPeptide {
369                            sequence,
370                            mass,
371                            decoy: p.decoy,
372                            missed_cleavages: p.missed_cleavages,
373                            proteins,
374                        }
375                    })
376                    .collect()
377            };
378
379        peptides.sort_by(|a, b| a.mass.partial_cmp(&b.mass).unwrap());
380
381        let n_decoys = peptides.iter().filter(|p| p.decoy).count();
382        let n_targets = peptides.len() - n_decoys;
383        PeptideDb {
384            peptides,
385            n_targets,
386            n_decoys,
387        }
388    }
389
390    pub fn len(&self) -> usize {
391        self.peptides.len()
392    }
393    pub fn is_empty(&self) -> bool {
394        self.peptides.is_empty()
395    }
396
397    /// Candidate peptides whose mass falls in `[lo, hi]` (inclusive), as a slice
398    /// of the sorted vector.
399    pub fn candidates(&self, lo: f64, hi: f64) -> &[TheoPeptide] {
400        let start = self.peptides.partition_point(|p| p.mass < lo);
401        let end = self.peptides.partition_point(|p| p.mass <= hi);
402        &self.peptides[start..end]
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn builds_human_ecoli_db() {
412        // The two-proteome entrapment FASTA the benchmark uses. Portable and
413        // in-repo (gitignored), rather than a path into a sibling checkout.
414        let Some(f) = crate::test_input("tests/data/entrap_human_yeast.fasta") else {
415            return;
416        };
417        let cfg = DigestConfig::default();
418        let db = PeptideDb::build(&f, &cfg).expect("build db");
419        assert!(db.n_targets > 100_000, "targets: {}", db.n_targets);
420        assert!(db.n_decoys > 0);
421
422        // sorted ascending
423        assert!(db.peptides.windows(2).all(|w| w[0].mass <= w[1].mass));
424
425        // A 10 ppm window around a mid-range mass yields a small candidate set.
426        // Probe an actual peptide mass rather than a round number: peptide masses
427        // cluster by mass defect, so a nominal 1500.0 falls in the gap between
428        // clusters and legitimately has no candidates within 10 ppm.
429        let m = db.peptides[db.peptides.len() / 2].mass;
430        let tol = m * 10e-6;
431        let c = db.candidates(m - tol, m + tol);
432        assert!(!c.is_empty());
433        assert!(c.iter().all(|p| (p.mass - m).abs() <= tol + 1e-9));
434        eprintln!(
435            "db: {} targets, {} decoys; {} candidates within 10 ppm of {}",
436            db.n_targets,
437            db.n_decoys,
438            c.len(),
439            m
440        );
441    }
442}