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, ¶ms)?, 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}