Skip to main content

koth_core/
lib.rs

1//! koth-core: the in-memory LC-MS feature detection stages of
2//! [koth](https://github.com/pgarrett-scripps/koth).
3//!
4//! This crate reads and writes no files and has no native dependencies. A
5//! caller brings its own spectra (for example from its own mzML, Thermo or
6//! Bruker reader) as [`Spectrum`] values and runs:
7//!
8//! 1. **Hill detection** – groups MS1 peaks across scans into chromatographic hills
9//! 2. **Feature finding** – groups hills into isotope envelopes with charge state assignment
10//! 3. **Scoring** – scores isotope pattern quality using the averagine model
11//!
12//! either stage by stage ([`run_hills`], [`run_features`], [`run_scoring`]) or
13//! in one call ([`run_pipeline_from_spectra`], or the streaming
14//! [`run_pipeline_streaming_from_spectra`] with a [`PipelineSink`]).
15//!
16//! The `koth-ms` crate (the `koth_ff` and `koth_align` executables) adds the
17//! file readers and writers and cross-run LFQ, and runs exactly these stages;
18//! it re-exports this crate's modules at their original `koth_ms::` paths.
19//!
20//! # Example
21//!
22//! ```
23//! use koth_core::{run_pipeline_from_spectra, KothConfig, Peak, PipelineOptions, Spectrum};
24//!
25//! let spectra: Vec<Spectrum> = (0..20)
26//!     .map(|i| Spectrum {
27//!         scan_index: i,
28//!         retention_time: i as f64 * 0.05,
29//!         peaks: vec![Peak { mz: 500.0, intensity: 1.0e5, ion_mobility: 0.0 }],
30//!         ms_level: 1,
31//!         isolation_window: None,
32//!         faims_cv: None,
33//!     })
34//!     .collect();
35//!
36//! let out = run_pipeline_from_spectra(
37//!     spectra.into_iter(),
38//!     &KothConfig::default(),
39//!     &PipelineOptions::default(),
40//! )?;
41//! println!("{} hills, {} features", out.hills.len(), out.features.len());
42//! # Ok::<(), koth_core::Error>(())
43//! ```
44
45pub mod config;
46pub mod error;
47pub mod features;
48pub mod hills;
49pub mod mem;
50pub mod models;
51pub mod pipeline;
52pub mod scoring;
53pub mod stats;
54
55pub use config::KothConfig;
56pub use error::{Error, Result};
57pub use models::{Feature, Hill, IsolationWindow, Peak, Polarity, ScoredFeature, Spectrum};
58pub use pipeline::{
59    group_ms2_hills_by_window, run_pipeline_from_hills, run_pipeline_from_spectra,
60    run_pipeline_streaming_from_spectra, FeatureFindingOutput, PipelineOptions, PipelineSink,
61};
62
63use std::collections::HashMap;
64
65use config::{FeaturesConfig, FileConfig, HillsConfig, ScoringConfig};
66use rand::seq::SliceRandom;
67
68/// Apply the decoy construction to a full spectrum set: when `file.decoy_mode`
69/// is set, shuffle the **entire** set (Fisher–Yates over `rand::thread_rng`)
70/// before hill detection; otherwise return it untouched.
71///
72/// This is the single definition of the "shuffle the whole set, then detect"
73/// step. Every entry point — in-memory ([`run_hills`]), `koth-ms`'s streaming
74/// file reader, and the MS2-buffered pipeline path
75/// ([`pipeline::run_pipeline_streaming_from_spectra`]) — routes its decoy shuffle
76/// through here so the RNG source and full-set semantics live in exactly one
77/// place.
78pub fn shuffle_if_decoy(mut spectra: Vec<Spectrum>, file: &FileConfig) -> Vec<Spectrum> {
79    if file.decoy_mode {
80        log::info!(
81            "Decoy mode: shuffling {} spectra before hill detection",
82            spectra.len()
83        );
84        spectra.shuffle(&mut rand::thread_rng());
85    }
86    spectra
87}
88
89/// Stage 1: Detect chromatographic hills from MS1 spectra.
90pub fn run_hills(spectra: &[Spectrum], config: &HillsConfig, file: &FileConfig) -> Vec<Hill> {
91    let spectra = shuffle_if_decoy(spectra.to_vec(), file);
92    hills::detect_hills(&spectra, config, file)
93}
94
95/// Stage 2: Detect isotope features from hills.
96pub fn run_features(
97    hills: &[Hill],
98    config: &FeaturesConfig,
99    file: &FileConfig,
100) -> Result<Vec<Feature>> {
101    if has_multiple_faims_channels(hills.iter().map(|h| h.faims_cv)) {
102        let mut groups: HashMap<Option<u32>, (Option<f32>, Vec<Hill>)> = HashMap::new();
103        for hill in hills {
104            let cv = hills::canonical_faims_cv(hill.faims_cv);
105            groups
106                .entry(cv.map(f32::to_bits))
107                .or_insert_with(|| (cv, Vec::new()))
108                .1
109                .push(hill.clone());
110        }
111        let mut groups: Vec<_> = groups.into_values().collect();
112        groups.sort_by(|a, b| hills::cmp_faims_cv(a.0, b.0));
113
114        let mut out = Vec::new();
115        for (cv, group) in groups {
116            log::info!(
117                "Detecting features for FAIMS CV {} ({} hills)",
118                cv.map_or_else(|| "missing".to_string(), |v| v.to_string()),
119                group.len()
120            );
121            out.extend(run_features_one_channel(&group, config, file)?);
122        }
123        return Ok(out);
124    }
125    run_features_one_channel(hills, config, file)
126}
127
128fn run_features_one_channel(
129    hills: &[Hill],
130    config: &FeaturesConfig,
131    file: &FileConfig,
132) -> Result<Vec<Feature>> {
133    if file.mz_recalibration {
134        if file.decoy_mode {
135            log::info!(
136                "Decoy mode active — skipping m/z recalibration (shuffled spectra would skew the surface)"
137            );
138        } else {
139            match features::learn_recal_model(hills, config, file) {
140                Some(model) => {
141                    log::info!(
142                        "m/z recalibration: learned isotope-consistency surface from {} spacing samples (global offset {:.3} ppm, σ {:.3} ppm) — applying in pass 2",
143                        model.n_samples,
144                        model.global_offset(),
145                        model.global_sigma,
146                    );
147                    return Ok(features::detect_features_with_recal(
148                        hills,
149                        config,
150                        file,
151                        Some(&model),
152                    ));
153                }
154                None => {
155                    log::warn!(
156                        "m/z recalibration enabled but too few isotope-spacing samples to build a surface — using uncorrected features"
157                    );
158                }
159            }
160        }
161    }
162    Ok(features::detect_features(hills, config, file))
163}
164
165/// Stage 3: Score isotope features using the averagine model.
166///
167/// Applies three AND-ed retention filters from `FeaturesConfig`:
168///   - `min_isotope_score` (Bhattacharyya vs averagine)
169///   - `min_cosine_score`  (mean chromatographic cosine of isotope hills)
170///   - `min_combined_score` (= isotope × cosine)
171///
172/// All thresholds at 0.0 keep every scored feature.
173pub fn run_scoring(
174    features: &[Feature],
175    scoring_cfg: &ScoringConfig,
176    features_cfg: &FeaturesConfig,
177    polarity: Polarity,
178) -> Vec<ScoredFeature> {
179    if has_multiple_faims_channels(features.iter().map(Feature::faims_cv)) {
180        let mut groups: HashMap<Option<u32>, (Option<f32>, Vec<Feature>)> = HashMap::new();
181        for feature in features {
182            let cv = hills::canonical_faims_cv(feature.faims_cv());
183            debug_assert!(
184                feature
185                    .hills
186                    .iter()
187                    .all(|h| hills::canonical_faims_cv(h.faims_cv) == cv),
188                "feature contains hills from different FAIMS CV channels"
189            );
190            groups
191                .entry(cv.map(f32::to_bits))
192                .or_insert_with(|| (cv, Vec::new()))
193                .1
194                .push(feature.clone());
195        }
196        let mut groups: Vec<_> = groups.into_values().collect();
197        groups.sort_by(|a, b| hills::cmp_faims_cv(a.0, b.0));
198
199        let mut out = Vec::new();
200        for (_cv, group) in groups {
201            out.extend(run_scoring_one_channel(
202                &group,
203                scoring_cfg,
204                features_cfg,
205                polarity,
206            ));
207        }
208        return out;
209    }
210    run_scoring_one_channel(features, scoring_cfg, features_cfg, polarity)
211}
212
213fn run_scoring_one_channel(
214    features: &[Feature],
215    scoring_cfg: &ScoringConfig,
216    features_cfg: &FeaturesConfig,
217    polarity: Polarity,
218) -> Vec<ScoredFeature> {
219    let mut scored = scoring::score_features(
220        features,
221        scoring_cfg,
222        &features_cfg.sulfur_offsets,
223        &features_cfg.isotope_model.model(),
224        polarity,
225    );
226    let any_filter = features_cfg.min_isotope_score > 0.0
227        || features_cfg.min_cosine_score > 0.0
228        || features_cfg.min_combined_score > 0.0;
229    if any_filter {
230        let before = scored.len();
231        scored.retain(|sf| {
232            sf.isotope_score >= features_cfg.min_isotope_score
233                && sf.cosine_score >= features_cfg.min_cosine_score
234                && sf.combined_score >= features_cfg.min_combined_score
235        });
236        log::info!(
237            "Retained {}/{} features after score filters (iso\u{2265}{:.2}, cos\u{2265}{:.2}, comb\u{2265}{:.2})",
238            scored.len(),
239            before,
240            features_cfg.min_isotope_score,
241            features_cfg.min_cosine_score,
242            features_cfg.min_combined_score
243        );
244    }
245    scored
246}
247
248fn has_multiple_faims_channels(values: impl Iterator<Item = Option<f32>>) -> bool {
249    let mut first: Option<Option<u32>> = None;
250    for value in values {
251        let key = hills::canonical_faims_cv(value).map(f32::to_bits);
252        match first {
253            None => first = Some(key),
254            Some(first_key) if first_key != key => return true,
255            Some(_) => {}
256        }
257    }
258    false
259}
260
261#[cfg(test)]
262mod shuffle_tests {
263    use super::*;
264    use config::FileConfig;
265
266    fn spectra(n: usize) -> Vec<Spectrum> {
267        (0..n)
268            .map(|i| Spectrum {
269                scan_index: i,
270                retention_time: i as f64 * 0.1,
271                peaks: Vec::new(),
272                ms_level: 1,
273                isolation_window: None,
274                faims_cv: None,
275            })
276            .collect()
277    }
278
279    /// Decoy off: the set is returned untouched, in the exact input order (the
280    /// non-decoy path the byte-parity tests exercise).
281    #[test]
282    fn no_decoy_is_identity() {
283        let file = FileConfig {
284            decoy_mode: false,
285            ..FileConfig::default()
286        };
287        let out = shuffle_if_decoy(spectra(64), &file);
288        let idx: Vec<usize> = out.iter().map(|s| s.scan_index).collect();
289        assert_eq!(idx, (0..64).collect::<Vec<_>>());
290    }
291
292    /// Decoy on: the FULL set is permuted with no drops or duplicates (a decoy
293    /// run must shuffle every spectrum, not a subset). Order is `thread_rng`, so
294    /// we assert the preserved multiset, which is the load-bearing invariant.
295    #[test]
296    fn decoy_permutes_full_set() {
297        let file = FileConfig {
298            decoy_mode: true,
299            ..FileConfig::default()
300        };
301        let out = shuffle_if_decoy(spectra(256), &file);
302        assert_eq!(out.len(), 256);
303        let mut idx: Vec<usize> = out.iter().map(|s| s.scan_index).collect();
304        idx.sort_unstable();
305        assert_eq!(idx, (0..256).collect::<Vec<_>>());
306    }
307}
308
309/// Compiles and runs the README's example as a doctest.
310#[cfg(doctest)]
311#[doc = include_str!("../README.md")]
312struct ReadmeDoctests;