Skip to main content

uno_core/
matching.rs

1//! Phase 3: match single-run features to theoretical peptides by precursor mass.
2//!
3//! For each feature we hypothesise that koth may have mis-assigned the
4//! monoisotopic peak by a small number of C13 neutrons. For each isotope offset
5//! `o`, the implied peptide mass is `feature.mass - o * NEUTRON`; we collect all
6//! peptides within `ppm_tolerance` of that center. Each (feature, peptide,
7//! offset) is one Peptide-Feature Match (PFM), the unit FDR is later applied to.
8
9use crate::database::PeptideDb;
10use crate::input::FeatureMatrix;
11use crate::mass::NEUTRON;
12use rayon::prelude::*;
13
14#[derive(Debug, Clone, serde::Deserialize)]
15#[serde(default)]
16pub struct MatchConfig {
17    /// Mass tolerance in ppm for the feature<->peptide match.
18    pub ppm_tolerance: f64,
19    /// Isotope-error offsets to try. `o` means "koth's mono is o neutrons heavier
20    /// than the true mono". `[0]` disables isotope-error search.
21    pub isotope_offsets: Vec<i8>,
22    /// Skip features below this charge. Singly-charged features are usually
23    /// non-peptide contaminants (and CCS predictors don't model them).
24    pub min_charge: u8,
25    /// Skip features built from fewer than this many isotopic peaks (0 = off).
26    /// The binomial background `p` is set by FEATURE density x mass window, not
27    /// by database size, so on an instrument with poor mass accuracy the only way
28    /// to keep `p` in range is to search fewer, better-characterised features.
29    /// On timsTOF (mass sigma ~3 ppm vs ~0.55 on Orbitrap) this is the difference
30    /// between 0 and ~300 proteins: 114,659 features -> p 0.554 -> 0 proteins;
31    /// gating to `min_isotopes = 4` + `min_combined_score = 0.85` leaves 17,587
32    /// -> p 0.206 -> 199-368 proteins at 1% honest entrapment FDP. The isotope
33    /// count is the load-bearing half — `min_combined_score` alone (36,753
34    /// features) still yields 0. Default 0 keeps Orbitrap byte-identical.
35    pub min_isotopes: u32,
36    /// Skip features whose `combined_score` (isotope x coelution) is below this
37    /// (0 = off). Use with `min_isotopes`; see that field for the measurements.
38    pub min_combined_score: f64,
39}
40
41impl Default for MatchConfig {
42    fn default() -> Self {
43        MatchConfig {
44            ppm_tolerance: 10.0,
45            isotope_offsets: vec![-1, 0, 1],
46            min_charge: 1,
47            min_isotopes: 0,
48            min_combined_score: 0.0,
49        }
50    }
51}
52
53/// One peptide-feature match.
54///
55/// Field order + widths are chosen to keep the struct at 24 bytes (down from 32):
56/// the `f64` leads (8-byte aligned), the two indices are `u32` (feature and
57/// peptide counts are well under 2^32 — 211k features, ~1.2M peptides on run10),
58/// and the two 1-byte tails pack into the trailing padding. At ~17M reduced PFMs
59/// this saves ~136 MB versus `usize` indices. Values are exact, so output is
60/// unchanged.
61#[derive(Debug, Clone)]
62pub struct Pfm {
63    /// Signed mass error (peptide - offset-corrected feature center), in ppm.
64    pub mass_error_ppm: f64,
65    pub feature_idx: u32,
66    pub peptide_idx: u32,
67    pub isotope_offset: i8,
68    pub decoy: bool,
69}
70
71/// Feature-quality gate shared by both match paths.
72pub(crate) fn passes(feature: &crate::input::Feature, cfg: &MatchConfig) -> bool {
73    feature.charge >= cfg.min_charge
74        && feature.combined_score >= cfg.min_combined_score
75        && (cfg.min_isotopes == 0
76            || feature
77                .n_isotopes
78                .is_none_or(|n| n >= cfg.min_isotopes as f64))
79}
80
81/// Search the union of member mass windows once per aligned group. Overlapping
82/// windows are merged, so a peptide/offset appears once regardless of run count.
83fn joint_matches(matrix: &FeatureMatrix, fi: usize, db: &PeptideDb, cfg: &MatchConfig) -> Vec<Pfm> {
84    let group = &matrix.joint_evidence.as_ref().unwrap()[fi];
85    let feature = &matrix.features[fi];
86    let mut out = Vec::new();
87    for &offset in &cfg.isotope_offsets {
88        let mut windows: Vec<(f64, f64)> = group
89            .iter()
90            .filter(|o| passes(&o.feature, cfg))
91            .filter_map(|o| {
92                let c = o.feature.mass - offset as f64 * NEUTRON;
93                (c > 0.0).then_some({
94                    (
95                        c * (1.0 - cfg.ppm_tolerance * 1e-6),
96                        c * (1.0 + cfg.ppm_tolerance * 1e-6),
97                    )
98                })
99            })
100            .collect();
101        windows.sort_by(|a, b| a.0.total_cmp(&b.0));
102        let mut merged: Vec<(f64, f64)> = Vec::new();
103        for (lo, hi) in windows {
104            if let Some(last) = merged.last_mut().filter(|p| p.1 >= lo) {
105                last.1 = last.1.max(hi);
106            } else {
107                merged.push((lo, hi));
108            }
109        }
110        let center = feature.mass - offset as f64 * NEUTRON;
111        for (lo, hi) in merged {
112            let start = db.peptides.partition_point(|p| p.mass < lo);
113            for (j, pep) in db.peptides[start..].iter().enumerate() {
114                if pep.mass > hi {
115                    break;
116                }
117                out.push(Pfm {
118                    feature_idx: fi as u32,
119                    peptide_idx: (start + j) as u32,
120                    isotope_offset: offset,
121                    mass_error_ppm: (pep.mass - center) / center * 1e6,
122                    decoy: pep.decoy,
123                });
124            }
125        }
126    }
127    out
128}
129
130/// Generate all PFMs for the feature matrix against the database.
131///
132/// `db.peptides` must be mass-sorted (it is, after [`PeptideDb::build`]); we use
133/// the original sorted index as `peptide_idx`, so downstream code can resolve a
134/// PFM back to its peptide via `db.peptides[pfm.peptide_idx]`.
135pub fn match_features(matrix: &FeatureMatrix, db: &PeptideDb, cfg: &MatchConfig) -> Vec<Pfm> {
136    matrix
137        .features
138        .par_iter()
139        .enumerate()
140        .flat_map_iter(|(fi, feature)| {
141            if matrix.joint_evidence.is_some() {
142                return joint_matches(matrix, fi, db, cfg).into_iter();
143            }
144            let mut out = Vec::new();
145            if !passes(feature, cfg) {
146                return out.into_iter();
147            }
148            for &offset in &cfg.isotope_offsets {
149                let center = feature.mass - (offset as f64) * NEUTRON;
150                if center <= 0.0 {
151                    continue;
152                }
153                let tol = center * cfg.ppm_tolerance * 1e-6;
154                let lo = center - tol;
155                let hi = center + tol;
156                let start = db.peptides.partition_point(|p| p.mass < lo);
157                for (j, pep) in db.peptides[start..].iter().enumerate() {
158                    if pep.mass > hi {
159                        break;
160                    }
161                    out.push(Pfm {
162                        feature_idx: fi as u32,
163                        peptide_idx: (start + j) as u32,
164                        isotope_offset: offset,
165                        mass_error_ppm: (pep.mass - center) / center * 1e6,
166                        decoy: pep.decoy,
167                    });
168                }
169            }
170            out.into_iter()
171        })
172        .collect()
173}
174
175/// Generate PFMs already reduced to the top-`k` mass matches per feature, split
176/// by target/decoy, without ever materialising the full (unreduced) PFM vector.
177///
178/// This is a memory-streaming fusion of [`match_features`] followed by the
179/// downstream `reduce_top_k`: per feature we collect the candidate PFMs (in the
180/// same offset-then-peptide scan order as [`match_features`]), then stable-sort
181/// each of the target/decoy buckets by `|mass_error_ppm|` and truncate to `k`.
182/// The emitted order — for each feature, its target bucket then its decoy bucket
183/// — is byte-for-byte the order `reduce_top_k` produces, so re-running
184/// `reduce_top_k` on this set is idempotent and downstream output is unchanged.
185/// The full ~41M-PFM intermediate (~1.3GB on run10) is never allocated.
186pub fn match_features_topk(
187    matrix: &FeatureMatrix,
188    db: &PeptideDb,
189    cfg: &MatchConfig,
190    k: usize,
191) -> Vec<Pfm> {
192    matrix
193        .features
194        .par_iter()
195        .enumerate()
196        .flat_map_iter(|(fi, feature)| {
197            let mut targets: Vec<Pfm> = Vec::new();
198            let mut decoys: Vec<Pfm> = Vec::new();
199            if matrix.joint_evidence.is_some() {
200                for pfm in joint_matches(matrix, fi, db, cfg) {
201                    if pfm.decoy {
202                        decoys.push(pfm);
203                    } else {
204                        targets.push(pfm);
205                    }
206                }
207            } else if passes(feature, cfg) {
208                for &offset in &cfg.isotope_offsets {
209                    let center = feature.mass - (offset as f64) * NEUTRON;
210                    if center <= 0.0 {
211                        continue;
212                    }
213                    let tol = center * cfg.ppm_tolerance * 1e-6;
214                    let lo = center - tol;
215                    let hi = center + tol;
216                    let start = db.peptides.partition_point(|p| p.mass < lo);
217                    for (j, pep) in db.peptides[start..].iter().enumerate() {
218                        if pep.mass > hi {
219                            break;
220                        }
221                        let pfm = Pfm {
222                            feature_idx: fi as u32,
223                            peptide_idx: (start + j) as u32,
224                            isotope_offset: offset,
225                            mass_error_ppm: (pep.mass - center) / center * 1e6,
226                            decoy: pep.decoy,
227                        };
228                        if pep.decoy {
229                            decoys.push(pfm);
230                        } else {
231                            targets.push(pfm);
232                        }
233                    }
234                }
235            }
236            for bucket in [&mut targets, &mut decoys] {
237                bucket.sort_by(|a, b| {
238                    a.mass_error_ppm
239                        .abs()
240                        .partial_cmp(&b.mass_error_ppm.abs())
241                        .unwrap()
242                });
243                bucket.truncate(k);
244            }
245            targets.into_iter().chain(decoys)
246        })
247        .collect()
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::database::DigestConfig;
254
255    #[test]
256    fn matches_real_data() {
257        let (Some(features), Some(fasta)) = (
258            crate::test_input("tests/data/09744.features.tsv"),
259            crate::test_input("tests/data/entrap_human_yeast.fasta"),
260        ) else {
261            return;
262        };
263
264        let matrix = FeatureMatrix::load(&features).unwrap();
265        let db = PeptideDb::build(&fasta, &DigestConfig::default()).unwrap();
266        let cfg = MatchConfig::default();
267        let pfms = match_features(&matrix, &db, &cfg);
268
269        let n_target = pfms.iter().filter(|p| !p.decoy).count();
270        let n_decoy = pfms.iter().filter(|p| p.decoy).count();
271        let n_feat_matched = {
272            let mut v: Vec<u32> = pfms.iter().map(|p| p.feature_idx).collect();
273            v.sort_unstable();
274            v.dedup();
275            v.len()
276        };
277        eprintln!(
278            "PFMs: {} ({} target, {} decoy); {}/{} features matched at least one peptide",
279            pfms.len(),
280            n_target,
281            n_decoy,
282            n_feat_matched,
283            matrix.len()
284        );
285        assert!(n_target > 0 && n_decoy > 0);
286        // All errors within tolerance. Necessary but weak: it holds for an empty
287        // match set and for a set that matched the wrong peptides, so the checks
288        // below assert the properties the search actually depends on.
289        assert!(pfms
290            .iter()
291            .all(|p| p.mass_error_ppm.abs() <= cfg.ppm_tolerance + 1e-6));
292
293        // Targets and decoys must arrive in comparable numbers. The decoy database
294        // is the null the whole FDR rests on, so a lopsided ratio means the null is
295        // not exchangeable with the targets, whatever the mass errors look like.
296        let ratio = n_decoy as f64 / n_target as f64;
297        assert!(
298            (0.5..=2.0).contains(&ratio),
299            "target/decoy PFM ratio is lopsided: {n_target} target vs {n_decoy} decoy"
300        );
301
302        // Every PFM must point at a real feature and a real peptide, and its charge
303        // must be one the matcher was allowed to consider.
304        assert!(pfms.iter().all(|p| (p.feature_idx as usize) < matrix.len()));
305        assert!(pfms
306            .iter()
307            .all(|p| (p.peptide_idx as usize) < db.peptides.len()));
308
309        // The isotope offset is applied on the NEUTRAL mass in carbon-13 units, so
310        // only the offsets the config lists may appear.
311        assert!(pfms
312            .iter()
313            .all(|p| cfg.isotope_offsets.contains(&p.isotope_offset)));
314
315        // Charges below the floor are skipped as non-peptide contaminants.
316        assert!(pfms
317            .iter()
318            .all(|p| passes(&matrix.features[p.feature_idx as usize], &cfg)));
319
320        // A real search matches a substantial fraction of features, not a handful.
321        assert!(
322            n_feat_matched * 4 > matrix.len(),
323            "only {n_feat_matched}/{} features matched a peptide",
324            matrix.len()
325        );
326    }
327}