Skip to main content

koth_ms/
pipeline.rs

1//! In-process / streaming feature-finding API.
2//!
3//! This module exposes the `koth_ff` MS1 pipeline as a **library seam** so that
4//! downstream tools can obtain the hills and isotope-chain features directly in
5//! memory, without `koth_ff` writing intermediate `hills.parquet` /
6//! `features.parquet` files that they then re-read.
7//!
8//! It is an *additional* entry point layered on top of the same staged
9//! functions the `koth_ff` binary uses ([`crate::run_hills_streaming`],
10//! [`crate::run_features`], [`crate::run_scoring`]). The binary's file-writing
11//! path is unchanged, and the output produced here is bit-for-bit what that
12//! path would serialize (see the parity tests in `pipeline_tests.rs`).
13//!
14//! # Streaming granularity — and why
15//!
16//! The pipeline has a hard data dependency: **isotope-chain assembly needs the
17//! complete hill set** (it builds an m/z-sorted index over every hill, then
18//! grows chains across it), and hills themselves are only finalized after the
19//! whole run is read (co-elution *splitting*, baseline *filtering* and stable
20//! *`hill_id` assignment* are all global post-passes). So there is no honest
21//! "emit a finalized hill mid-scan" granularity: a hill's identity isn't known
22//! until end-of-read.
23//!
24//! The API therefore reflects the algorithm rather than pretending otherwise:
25//!
26//! 1. With default settings, spectra arrive through bounded buffers during hill
27//!    detection. Native readers retain scan metadata; completed hills are kept.
28//!    See [`crate::run_hills_streaming`] for options that still collect spectra.
29//! 2. When detection completes, the sink receives the **whole finalized hill
30//!    set once**, by value ([`PipelineSink::on_hills`]).
31//! 3. Features are then assembled and handed to the sink **one at a time, by
32//!    value** ([`PipelineSink::on_feature`]) and freed as they are consumed — so
33//!    a streaming consumer folds each feature into its own structures without
34//!    ever holding the full feature vector.
35//!
36//! # Consumers
37//!
38//! * **`koth_tracer`** (DIA pseudo-spectra) implements [`PipelineSink`]: it
39//!   takes ownership of the hill set in `on_hills` to build its trace index,
40//!   then consumes features incrementally in `on_feature`. With
41//!   [`PipelineOptions::emit_ms2`] it *also* receives the DIA fragment hills in
42//!   `on_ms2_hills`, partitioned by precursor isolation window, so it can map a
43//!   precursor feature to the window(s) that isolated it.
44//! * **`uno`** (MS1 search) just wants the final feature list: it calls
45//!   [`run_pipeline`] / [`run_pipeline_from_spectra`] and reads
46//!   [`FeatureFindingOutput::features`].
47//!
48//! # MS2 (DIA fragment hills) — opt-in
49//!
50//! MS2 fragment hill detection ([`crate::run_ms2_hills_streaming`]) is exposed
51//! through the *same* seam but is **off by default**. When
52//! [`PipelineOptions::emit_ms2`] is set, the MS2 hills are delivered once via
53//! [`PipelineSink::on_ms2_hills`] (or [`FeatureFindingOutput::ms2_hills`]) after
54//! the whole MS1 side. Each MS2 hill carries its precursor isolation window in
55//! [`Hill::isolation_window`], and [`group_ms2_hills_by_window`] groups them per
56//! DIA channel. MS2 is supported on **mzML**, **Bruker diaPASEF `.d`**
57//! (`--features tdf`), and **Thermo DIA `.raw`** (`--features thermo`); DDA
58//! acquisitions (ddaPASEF `.d`, DDA `.raw`) degrade to an empty MS2 set + a
59//! warning, leaving MS1 untouched. With `emit_ms2` false the MS1 path is
60//! byte-for-byte identical to before.
61
62use std::path::Path;
63
64use crate::config::KothConfig;
65use crate::error::KothError;
66use crate::models::{Feature, Hill, IsolationWindow, ScoredFeature, Spectrum};
67
68/// Knobs that mirror the `koth_ff` binary's optional stages. Everything else is
69/// taken from the [`KothConfig`] passed in explicitly.
70#[derive(Debug, Clone)]
71pub struct PipelineOptions {
72    /// Run the averagine isotope-pattern scoring stage. `true` mirrors the
73    /// binary's default; `false` mirrors `--no-scoring` (features are still
74    /// emitted, wrapped as [`ScoredFeature`] with zeroed scores).
75    pub scoring: bool,
76    /// Also run MS2 hill detection (DIA, partitioned by precursor isolation
77    /// window) over the same input, and deliver the finalized MS2 hill set via
78    /// [`PipelineSink::on_ms2_hills`] (and [`FeatureFindingOutput::ms2_hills`]).
79    ///
80    /// **Default `false`.** When `false`, the MS1 feature path is byte-for-byte
81    /// unchanged — no MS2 reader is opened and no MS2 hook fires; this exactly
82    /// reproduces the historic MS1-only [`run_pipeline`] behavior.
83    ///
84    /// MS2 detection mirrors [`crate::run_ms2_hills_streaming`]: mzML, Bruker
85    /// diaPASEF `.d` (`--features tdf`), and Thermo DIA `.raw` (`--features
86    /// thermo`) are supported; DDA acquisitions (ddaPASEF `.d`, DDA `.raw`) yield
87    /// an empty MS2 set plus a warning (the MS1 result is unaffected). MS2
88    /// detection is independent of MS1 feature finding — they share only the input.
89    pub emit_ms2: bool,
90}
91
92impl Default for PipelineOptions {
93    fn default() -> Self {
94        Self {
95            scoring: true,
96            emit_ms2: false,
97        }
98    }
99}
100
101/// A consumer notified as pipeline stages finalize.
102///
103/// Both methods have defaults, so a consumer implements only what it needs.
104/// The call contract is: `on_hills` **exactly once**, then `on_feature`
105/// **once per feature**, in detection order (the same set and order the file
106/// writer receives before it applies its own charge>0 filter and
107/// intensity-descending sort).
108pub trait PipelineSink {
109    /// Called once, when hill detection has fully completed. The hills carry
110    /// their final `hill_id`s (post-split, post-filter) and are identical to
111    /// the rows of `hills.{tsv,parquet}`. Ownership is transferred: feature
112    /// structs already hold their own (cheap, `Arc`-backed) hill clones, so the
113    /// pipeline no longer needs this vector.
114    fn on_hills(&mut self, hills: Vec<Hill>) {
115        let _ = hills;
116    }
117
118    /// Called once per finalized feature, by value, in detection order.
119    fn on_feature(&mut self, feature: ScoredFeature) {
120        let _ = feature;
121    }
122
123    /// Called **at most once**, with the whole finalized MS2 hill set, by value,
124    /// **only when [`PipelineOptions::emit_ms2`] is `true`**. Default no-op.
125    ///
126    /// MS2 hills are the DIA fragment traces produced by
127    /// [`crate::run_ms2_hills_streaming`] — detected independently of MS1, and
128    /// partitioned by precursor isolation window. Each hill carries its window
129    /// in [`Hill::isolation_window`] (`Some(..)` for every MS2 hill), so a
130    /// consumer can map an MS1 precursor feature to the window(s) that isolated
131    /// it by testing `window.lower <= precursor_mz <= window.upper`. Group them
132    /// per window with [`group_ms2_hills_by_window`].
133    ///
134    /// Called after `on_hills` and all `on_feature` calls, so a consumer already
135    /// holds the MS1 side before the MS2 hills arrive. Not called at all when
136    /// `emit_ms2` is `false`, nor (with a warning) when the input is not mzML.
137    fn on_ms2_hills(&mut self, hills: Vec<Hill>) {
138        let _ = hills;
139    }
140}
141
142/// Group a finalized MS2 hill set by its precursor isolation window, in a
143/// deterministic order (sorted by [`IsolationWindow::key`]).
144///
145/// MS1 hills (those with `isolation_window == None`) are skipped, so this is
146/// safe to pass any hill slice. This is the convenience shape a `koth_tracer`
147/// consumer uses to walk one DIA channel at a time; the same information is
148/// available per hill via [`Hill::isolation_window`] without grouping.
149pub fn group_ms2_hills_by_window(hills: Vec<Hill>) -> Vec<(IsolationWindow, Vec<Hill>)> {
150    use std::collections::BTreeMap;
151    // BTreeMap keyed on the stable integer window key → deterministic order.
152    let mut groups: BTreeMap<(i64, i64, i64), (IsolationWindow, Vec<Hill>)> = BTreeMap::new();
153    for h in hills {
154        let Some(iw) = h.isolation_window else {
155            continue;
156        };
157        groups
158            .entry(iw.key())
159            .or_insert_with(|| (iw, Vec::new()))
160            .1
161            .push(h);
162    }
163    groups.into_values().collect()
164}
165
166/// Owned, in-memory result of a full run — the convenience shape for consumers
167/// (like `uno`) that just want the final lists rather than a streaming callback.
168///
169/// `features` is exactly the vector the binary hands to its feature writer
170/// **before** the writer's own presentation step (which drops `charge == 0`
171/// features and sorts by descending intensity). Consumers that want the
172/// on-disk row set can replicate that with
173/// `features.iter().filter(|f| f.feature.charge > 0)`.
174#[derive(Debug, Default)]
175pub struct FeatureFindingOutput {
176    pub hills: Vec<Hill>,
177    pub features: Vec<ScoredFeature>,
178    /// Finalized MS2 (DIA fragment) hills, partitioned by precursor isolation
179    /// window — populated **only** when [`PipelineOptions::emit_ms2`] is `true`
180    /// (empty otherwise). Each carries its window in [`Hill::isolation_window`];
181    /// group with [`group_ms2_hills_by_window`].
182    pub ms2_hills: Vec<Hill>,
183}
184
185/// Wrap an unscored [`Feature`] as a [`ScoredFeature`] with zeroed scores —
186/// byte-for-byte what the binary's `--no-scoring` path emits.
187pub(crate) fn wrap_unscored(f: Feature) -> ScoredFeature {
188    ScoredFeature {
189        cosine_score: f.cosine_score,
190        feature: f,
191        neutron_offset: 0,
192        isotope_score: 0.0,
193        combined_score: 0.0,
194        theoretical_pattern: Vec::new(),
195    }
196}
197
198/// Core: given a finalized hill set, detect + score features and drive `sink`.
199/// Hills are handed to the sink *after* feature detection (which borrows them);
200/// features already own their hill clones, so this transfers ownership rather
201/// than cloning.
202fn drive_from_hills<S: PipelineSink>(
203    hills: Vec<Hill>,
204    config: &KothConfig,
205    opts: &PipelineOptions,
206    sink: &mut S,
207) -> Result<(), KothError> {
208    // Stage 2: isotope-chain features (needs the whole hill set). Reuses the
209    // exact crate-level entry the binary calls, including the m/z-recalibration
210    // branch keyed on `file.mz_recalibration`.
211    let features = crate::run_features(&hills, &config.features, &config.file)?;
212
213    // Stage 3: scoring (optional) — produced before we release the hills so the
214    // ordering the writer would see is preserved.
215    let scored: Vec<ScoredFeature> = if opts.scoring {
216        crate::run_scoring(
217            &features,
218            &config.scoring,
219            &config.features,
220            config.file.polarity,
221        )
222    } else {
223        features.into_iter().map(wrap_unscored).collect()
224    };
225
226    // Hand the finalized hill set to the sink (by value) now that features have
227    // been built and own their own hill clones.
228    sink.on_hills(hills);
229
230    // Stream features out one at a time, freeing each as it is consumed.
231    for sf in scored {
232        sink.on_feature(sf);
233    }
234    Ok(())
235}
236
237/// Run the full pipeline from a file path, streaming results to `sink`.
238/// No output files are written.
239///
240/// Hill detection routes through [`crate::run_hills_streaming`], using bounded
241/// MS1 spectrum buffers for mzML, Bruker `.d`, and Thermo `.raw`. Decoy shuffling
242/// and optional TIC normalization collect spectra before detection.
243pub fn run_pipeline_streaming<S: PipelineSink>(
244    path: &Path,
245    config: &KothConfig,
246    opts: &PipelineOptions,
247    sink: &mut S,
248) -> Result<(), KothError> {
249    let hills = crate::run_hills_streaming(path, &config.hills, &config.file)?;
250    drive_from_hills(hills, config, opts, sink)?;
251    // MS2 is opt-in and independent of MS1: only after the whole MS1 side has
252    // been delivered do we (re)read the input for DIA fragment hills. When
253    // `emit_ms2` is false this branch is skipped entirely, so the MS1-only path
254    // is byte-for-byte unchanged. mzML-only: `.d`/`.raw` return an empty set +
255    // warning via `run_ms2_hills_streaming`.
256    if opts.emit_ms2 {
257        // MS2 hill detection uses the MS2-resolved config: `[hills]` with any
258        // `[hills_ms2]` overrides applied. Absent `[hills_ms2]` ⇒ same as MS1.
259        let ms2 = crate::run_ms2_hills_streaming(path, &config.ms2_hills(), &config.file)?;
260        sink.on_ms2_hills(ms2);
261    }
262    Ok(())
263}
264
265/// Run the full pipeline from an in-memory iterator of spectra, streaming
266/// results to `sink`. No files are written and no `Vec<Spectrum>` is retained
267/// (unless `hills.tic_norm_window > 0` or `file.decoy_mode`, which inherently
268/// need the whole set — matching the binary).
269///
270/// This is the file-free entry point: callers that already hold spectra (or
271/// synthesize them, as the parity tests do) use this.
272pub fn run_pipeline_streaming_from_spectra<I, S>(
273    spectra: I,
274    config: &KothConfig,
275    opts: &PipelineOptions,
276    sink: &mut S,
277) -> Result<(), KothError>
278where
279    I: Iterator<Item = Spectrum>,
280    S: PipelineSink,
281{
282    if opts.emit_ms2 {
283        // MS2 hills are detected over the same spectra as MS1, but a streaming
284        // iterator can only be consumed once — so when MS2 is requested we
285        // buffer the input once and run both detectors over it (MS2 is
286        // independent of MS1). This mirrors what the file path does with two
287        // reads, without a second pass over the source. Detect MS2 from the
288        // *unshuffled* buffer (isolation-window hills need RT order); the MS1
289        // decoy shuffle, if any, applies only to the MS1 path below.
290        let buf: Vec<Spectrum> = spectra.collect();
291        // MS2 detector filters to `ms_level == 2` internally, so hand it the
292        // whole buffer. `detect_ms2_hills_from_iter` groups by isolation window.
293        let ms2 = crate::hills::detect_ms2_hills_from_iter(
294            buf.iter().cloned(),
295            &config.ms2_hills(),
296            &config.file,
297        );
298        // The MS1 detector does *not* filter by level (it processes every
299        // spectrum it is given), so we must exclude MS2 scans here — otherwise
300        // fragment peaks would pollute the MS1 hills. On the file path the MS1
301        // and MS2 readers are already separate streams; this reproduces that.
302        let ms1: Vec<Spectrum> = buf.into_iter().filter(|s| s.ms_level != 2).collect();
303        let ms1 = crate::shuffle_if_decoy(ms1, &config.file);
304        let hills =
305            crate::hills::detect_hills_from_iter(ms1.into_iter(), &config.hills, &config.file);
306        drive_from_hills(hills, config, opts, sink)?;
307        sink.on_ms2_hills(ms2);
308        return Ok(());
309    }
310
311    let hills = if config.file.decoy_mode {
312        // Decoy mode needs the full set to shuffle before detection, matching
313        // `crate::run_hills` / the binary's mzML decoy branch.
314        let buf = crate::shuffle_if_decoy(spectra.collect(), &config.file);
315        crate::hills::detect_hills_from_iter(buf.into_iter(), &config.hills, &config.file)
316    } else {
317        crate::hills::detect_hills_from_iter(spectra, &config.hills, &config.file)
318    };
319    drive_from_hills(hills, config, opts, sink)
320}
321
322/// A [`PipelineSink`] that simply collects everything into owned vectors.
323#[derive(Debug, Default)]
324struct CollectingSink {
325    out: FeatureFindingOutput,
326}
327
328impl PipelineSink for CollectingSink {
329    fn on_hills(&mut self, hills: Vec<Hill>) {
330        self.out.hills = hills;
331    }
332    fn on_feature(&mut self, feature: ScoredFeature) {
333        self.out.features.push(feature);
334    }
335    fn on_ms2_hills(&mut self, hills: Vec<Hill>) {
336        self.out.ms2_hills = hills;
337    }
338}
339
340/// Convenience: run the pipeline from a file path and collect all hills and
341/// features into owned vectors. The all-in-memory shape `uno` uses.
342pub fn run_pipeline(
343    path: &Path,
344    config: &KothConfig,
345    opts: &PipelineOptions,
346) -> Result<FeatureFindingOutput, KothError> {
347    let mut sink = CollectingSink::default();
348    run_pipeline_streaming(path, config, opts, &mut sink)?;
349    Ok(sink.out)
350}
351
352/// Convenience: run the pipeline from an in-memory spectrum iterator and
353/// collect all hills and features into owned vectors.
354pub fn run_pipeline_from_spectra<I>(
355    spectra: I,
356    config: &KothConfig,
357    opts: &PipelineOptions,
358) -> Result<FeatureFindingOutput, KothError>
359where
360    I: Iterator<Item = Spectrum>,
361{
362    let mut sink = CollectingSink::default();
363    run_pipeline_streaming_from_spectra(spectra, config, opts, &mut sink)?;
364    Ok(sink.out)
365}
366
367/// Convenience: run the full pipeline **plus** MS2 (DIA fragment) hill detection
368/// from a file path, collecting MS1 hills, MS1 features, and MS2 hills into owned
369/// vectors. The combined shape a `koth_tracer` consumer uses when it wants both
370/// sides at once rather than a streaming sink.
371///
372/// This forces MS2 emission on (equivalent to setting
373/// [`PipelineOptions::emit_ms2`] `true`); the rest of `opts` is honored as-is.
374/// mzML-only for the MS2 side: Bruker `.d` / Thermo `.raw` yield an empty
375/// `ms2_hills` plus a warning, leaving the MS1 result unaffected.
376///
377/// MS2 fragment hills are detected with [`KothConfig::ms2_hills`] — `[hills]`
378/// plus any `[hills_ms2]` overrides — so an in-process caller drives MS1 and MS2
379/// hill settings independently from one `KothConfig`. With no `[hills_ms2]` the
380/// MS2 side uses `[hills]` verbatim.
381pub fn run_pipeline_with_ms2(
382    path: &Path,
383    config: &KothConfig,
384    opts: &PipelineOptions,
385) -> Result<FeatureFindingOutput, KothError> {
386    let opts = PipelineOptions {
387        emit_ms2: true,
388        ..opts.clone()
389    };
390    let mut sink = CollectingSink::default();
391    run_pipeline_streaming(path, config, &opts, &mut sink)?;
392    Ok(sink.out)
393}
394
395#[cfg(test)]
396#[path = "pipeline_tests.rs"]
397mod tests;