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}