Skip to main content

koth_core/features/
mod.rs

1pub mod cosine;
2pub mod recalibration;
3
4mod assemble;
5
6use std::cmp::Ordering;
7
8use crate::config::{FeaturesConfig, FileConfig, ToleranceType};
9use crate::models::{Feature, Hill};
10use assemble::{resolve_exhaustive, ChainCtx};
11use cosine::cosine_similarity;
12use recalibration::{MzRecalBuilder, MzRecalModel};
13
14/// Detect isotope features from a list of chromatographic hills.
15///
16/// 1. Build an m/z-sorted index of the hills.
17/// 2. Every `(seed hill, charge)` pair builds an isotope chain by extending
18///    upward only (M+1, M+2, …) with chromatographic-cosine filtering (gated by
19///    `FeaturesConfig.min_chain_cosine`); the seed is the monoisotope
20///    hypothesis — see `build_charge_candidate`.
21/// 3. The over-complete candidate pool is resolved non-destructively by
22///    `resolve_exhaustive`: contested hills are claimed by descending isotope log evidence
23///    using each candidate's best-scoring valid prefix. After a conflict, the
24///    best remaining free prefix is requeued at equal or lower priority.
25/// 4. Feature structs are built from the accepted candidates.
26pub fn detect_features(hills: &[Hill], config: &FeaturesConfig, file: &FileConfig) -> Vec<Feature> {
27    detect_features_with_recal(hills, config, file, None)
28}
29
30/// As [`detect_features`], but shifts the expected isotope position during
31/// chain extension by the per-region offset from an isotope-consistency
32/// recalibration surface (see [`recalibration`]). Pass `None` for the
33/// uncorrected behaviour.
34pub fn detect_features_with_recal(
35    hills: &[Hill],
36    config: &FeaturesConfig,
37    file: &FileConfig,
38    recal: Option<&MzRecalModel>,
39) -> Vec<Feature> {
40    if hills.is_empty() {
41        return Vec::new();
42    }
43
44    log::info!("Detecting isotope features from {} hills", hills.len());
45
46    let mut order: Vec<usize> = (0..hills.len()).collect();
47    order.sort_by(|&a, &b| {
48        hills[a]
49            .mz
50            .partial_cmp(&hills[b].mz)
51            .unwrap_or(Ordering::Equal)
52    });
53    let sorted_hills: Vec<&Hill> = order.iter().map(|&i| &hills[i]).collect();
54    let mz_array: Vec<f64> = sorted_hills.iter().map(|h| h.mz).collect();
55    let im_array: Vec<f64> = sorted_hills.iter().map(|h| h.im).collect();
56    let scan_starts: Vec<usize> = sorted_hills.iter().map(|h| h.scan_start).collect();
57    let scan_ends: Vec<usize> = sorted_hills.iter().map(|h| h.scan_end).collect();
58
59    let use_im = im_array.iter().any(|&x| x != 0.0);
60
61    // Per-run noise floor for the predicted-intensity early-stop gate in
62    // `build_charge_candidate` (only consulted when
63    // `chain_predicted_intensity_gate` is true; default off). That gate compares
64    // it against an *apex*-basis predicted isotope intensity
65    // (`seed_hill.intensity_max × template_ratio`), so the floor is derived from
66    // the same apex statistic (`intensity_max`, 5th-pct × 0.8) rather than from
67    // `intensity_sum` — otherwise `intensity_max ≤ intensity_sum` would make the
68    // gate compare apex-vs-sum and fire stricter than intended.
69    let min_intensity = {
70        let mut maxes: Vec<f64> = hills.iter().map(|h| h.intensity_max).collect();
71        maxes.sort_by(|a, b| a.partial_cmp(b).unwrap());
72        if maxes.is_empty() {
73            0.0
74        } else {
75            let idx = (maxes.len() as f64 * 0.05) as usize;
76            maxes[idx] * 0.8
77        }
78    };
79
80    // Non-destructive assembler (biosaur2 / AlphaPept style): an over-complete
81    // (seed, charge) hypothesis pool, generated in parallel and resolved by
82    // claiming each candidate's best valid prefix by descending isotope log
83    // evidence. Conflicts restrict selection to still-free prefixes before
84    // requeuing. See `resolve_exhaustive`.
85    let ctx = ChainCtx {
86        sorted_hills: &sorted_hills,
87        mz_array: &mz_array,
88        im_array: &im_array,
89        scan_starts: &scan_starts,
90        scan_ends: &scan_ends,
91        config,
92        file,
93        use_im,
94        min_intensity,
95        recal,
96    };
97    let accepted = resolve_exhaustive(&ctx);
98
99    // Phase 4: build Feature structs.
100    let features: Vec<Feature> = accepted
101        .into_iter()
102        .map(|c| {
103            let mut chain_hills: Vec<Hill> = c
104                .hill_indices
105                .iter()
106                .map(|&i| (*sorted_hills[i]).clone())
107                .collect();
108            chain_hills.sort_by(|a, b| a.mz.partial_cmp(&b.mz).unwrap_or(Ordering::Equal));
109
110            let cosine_sims: Vec<f64> = (0..chain_hills.len().saturating_sub(1))
111                .map(|k| {
112                    cosine_similarity(
113                        &chain_hills[k],
114                        &chain_hills[k + 1],
115                        config.min_scan_overlap,
116                    )
117                })
118                .collect();
119
120            let ppm_errors: Vec<f64> = if c.charge > 0 && chain_hills.len() > 1 {
121                let step = config.neutron_mass / c.charge as f64;
122                (0..chain_hills.len() - 1)
123                    .map(|k| {
124                        let theo = chain_hills[k].mz + step;
125                        let expt = chain_hills[k + 1].mz;
126                        (expt - theo) / theo * 1e6
127                    })
128                    .collect()
129            } else {
130                Vec::new()
131            };
132
133            let mean_cosine = if cosine_sims.is_empty() {
134                0.0
135            } else {
136                cosine_sims.iter().sum::<f64>() / cosine_sims.len() as f64
137            };
138            let mean_ppm = if ppm_errors.is_empty() {
139                0.0
140            } else {
141                ppm_errors.iter().sum::<f64>() / ppm_errors.len() as f64
142            };
143
144            let charge = if c.hill_indices.len() > 1 {
145                c.charge
146            } else {
147                0
148            };
149
150            Feature {
151                hills: chain_hills,
152                charge,
153                cosine_score: mean_cosine,
154                ppm_error: mean_ppm,
155            }
156        })
157        .collect();
158
159    log::info!("Detected {} isotope features", features.len());
160    features
161}
162
163/// Learn an isotope-consistency recalibration surface from a pass-1 feature
164/// detection over `hills`. Collects, for every accepted charged feature with
165/// ≥2 isotope hills, the signed ppm deviation of each adjacent isotope-hill
166/// spacing from the theoretical `neutron_mass / z` step, keyed at the higher
167/// hill's (m/z, RT). Returns `None` when too few samples are collected to be
168/// meaningful (see `mz_recalibration_min_samples`).
169pub fn learn_recal_model(
170    hills: &[Hill],
171    config: &FeaturesConfig,
172    file: &FileConfig,
173) -> Option<MzRecalModel> {
174    let features = detect_features_with_recal(hills, config, file, None);
175
176    // Generous residual cap so a spacing that skipped an isotope can't poison
177    // the marginal fallback: 4× the user tolerance (ppm), or a fixed 40 ppm
178    // for Dalton tolerances where a ppm cap isn't defined.
179    let max_abs_ppm = if matches!(file.mz_tolerance_type, ToleranceType::Ppm) {
180        file.mz_tolerance * 4.0
181    } else {
182        40.0
183    };
184    let mut builder = MzRecalBuilder::new(max_abs_ppm);
185
186    for f in &features {
187        if f.charge < 1 || f.hills.len() < 2 {
188            continue;
189        }
190        let step = config.neutron_mass / f.charge as f64;
191        // `f.hills` is sorted by m/z ascending and chain extension stops at the
192        // first missing isotope, so adjacent pairs are consecutive isotopes.
193        // The 0.5·step guard is a cheap belt-and-braces against any gap.
194        for w in f.hills.windows(2) {
195            let observed = w[1].mz - w[0].mz;
196            if (observed - step).abs() > 0.5 * step {
197                continue;
198            }
199            let resid_ppm = (observed - step) / w[1].mz * 1e6;
200            builder.add(w[1].mz, w[1].rt, resid_ppm);
201        }
202    }
203
204    builder.finalize(
205        file.mz_recalibration_mz_bins,
206        file.mz_recalibration_rt_bins,
207        file.mz_recalibration_min_samples,
208    )
209}