Skip to main content

koth_core/
pipeline.rs

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