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;