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}