1pub 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
68pub 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
89pub 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
95pub 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
165pub 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 #[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 #[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#[cfg(doctest)]
311#[doc = include_str!("../README.md")]
312struct ReadmeDoctests;