Skip to main content

uno_core/
joint.rs

1//! Candidate-specific evidence from the original observations of an aligned row.
2//! Measurements are features of one discriminant, NOT independent probabilities.
3use crate::input::Feature;
4
5#[derive(Debug, Clone)]
6pub struct Observation {
7    /// Group clears KOTH's original seed floor; weak replicated groups can be
8    /// searched without defining the initial mass/RT calibration.
9    pub bootstrap_eligible: bool,
10    /// Duplicate source-table hashes share an evidence run ID at ingestion.
11    pub run_id: usize,
12    pub observation_id: usize,
13    /// Original quality, aligned mass/RT/IM. Intensities contain original apex only.
14    pub feature: Feature,
15}
16
17#[derive(Debug, Clone)]
18pub struct Evidence {
19    pub best: usize,
20    pub mass_error: f64,
21    pub rt_error: f64,
22    pub deviation: f64,
23    /// Candidate-specific agreement/quality summaries (see NAMES).
24    pub values: [f32; 14],
25}
26
27pub struct Match {
28    pub best: usize,
29    pub mass_error: f64,
30    pub rt_error: f64,
31    pub deviation: f64,
32}
33
34pub const NAMES: [&str; 14] = [
35    "direct_runs",
36    "compatible_runs",
37    "compatible_fraction",
38    "second_deviation",
39    "median_deviation",
40    "median_mass_error",
41    "mass_mad",
42    "median_rt_error",
43    "rt_mad",
44    "median_quality",
45    "median_isotopes",
46    "median_scans",
47    "best_quality",
48    "has_second_run",
49];
50
51/// Apply user-specified matching quality gates to both matching AND scoring.
52/// Otherwise a candidate found on an eligible member could be scored using a
53/// different observation that the user's isotope/quality settings excluded.
54pub fn restrict_observations(matrix: &mut crate::FeatureMatrix, cfg: &crate::MatchConfig) {
55    let Some(groups) = matrix.joint_evidence.take() else {
56        return;
57    };
58    let cells = matrix.joint_cell_evidence.take();
59    let mut retained_cells = Vec::new();
60    let mut features = Vec::new();
61    let mut retained = Vec::new();
62    for (i, (mut f, mut group)) in std::mem::take(&mut matrix.features)
63        .into_iter()
64        .zip(groups)
65        .enumerate()
66    {
67        let before = group.len();
68        group.retain(|o| crate::matching::passes(&o.feature, cfg));
69        if group.is_empty() {
70            continue;
71        }
72        if group.len() != before {
73            let representative = group
74                .iter()
75                .max_by(|a, b| {
76                    a.feature
77                        .n_isotopes
78                        .is_some_and(|n| n >= 3.0)
79                        .cmp(&b.feature.n_isotopes.is_some_and(|n| n >= 3.0))
80                        .then(
81                            a.feature
82                                .combined_score
83                                .total_cmp(&b.feature.combined_score),
84                        )
85                        .then_with(|| {
86                            (b.run_id, b.observation_id).cmp(&(a.run_id, a.observation_id))
87                        })
88                })
89                .unwrap();
90            let intensities = f.intensities;
91            f = representative.feature.clone();
92            f.intensities = intensities;
93            f.n_contributing_runs = group
94                .iter()
95                .map(|o| o.run_id)
96                .collect::<std::collections::HashSet<_>>()
97                .len() as u32;
98        }
99        if let Some(cells) = &cells {
100            retained_cells.push(cells[i]);
101        }
102        features.push(f);
103        retained.push(group);
104    }
105    matrix.features = features;
106    matrix.joint_evidence = Some(retained);
107    matrix.joint_cell_evidence = cells.map(|_| retained_cells);
108}
109
110fn med(v: &mut [f64]) -> f64 {
111    v.sort_unstable_by(f64::total_cmp);
112    let n = v.len();
113    if n.is_multiple_of(2) {
114        (v[n / 2 - 1] + v[n / 2]) / 2.0
115    } else {
116        v[n / 2]
117    }
118}
119
120/// Best *simultaneous* mass/RT match, at most one observation per source run.
121/// A strong singleton survives; absent runs and MBR cells do not enter the model.
122/// Alternatives can replace a poorer peak, but cannot create additional support.
123pub fn evidence(
124    observations: &[Observation],
125    peptide_mass: f64,
126    predicted_rt: f64,
127    mass_shift: f64,
128    mass_sigma: f64,
129    rt_sigma: f64,
130    mass_gate: f64,
131) -> Option<Evidence> {
132    let mut by_run: Vec<(usize, usize, f64, f64, f64)> = Vec::new();
133    for (i, o) in observations.iter().enumerate() {
134        let mass = (peptide_mass - o.feature.mass) / o.feature.mass * 1e6;
135        let rt = o.feature.rt - predicted_rt;
136        let mz = (mass - mass_shift) / mass_sigma;
137        let dev = mz * mz + (rt / rt_sigma).powi(2);
138        // Group observations are stored by run at load time. This remains correct
139        // for arbitrary order in callers and tests, and small cohorts keep it cheap.
140        if let Some(prior) = by_run.iter_mut().find(|p| p.0 == o.run_id) {
141            let inside = (mass - mass_shift).abs() <= mass_gate;
142            let prior_inside = (prior.3 - mass_shift).abs() <= mass_gate;
143            let better_dev = dev < prior.2
144                || (dev == prior.2
145                    && o.feature.combined_score > observations[prior.1].feature.combined_score);
146            if (inside && !prior_inside) || (inside == prior_inside && better_dev) {
147                *prior = (o.run_id, i, dev, mass, rt);
148            }
149        } else {
150            by_run.push((o.run_id, i, dev, mass, rt));
151        }
152    }
153    by_run.sort_unstable_by(|a, b| {
154        ((b.3 - mass_shift).abs() <= mass_gate)
155            .cmp(&((a.3 - mass_shift).abs() <= mass_gate))
156            .then(a.2.total_cmp(&b.2))
157            .then_with(|| {
158                observations[b.1]
159                    .feature
160                    .combined_score
161                    .total_cmp(&observations[a.1].feature.combined_score)
162            })
163            .then(a.0.cmp(&b.0))
164    });
165    let best = by_run
166        .iter()
167        .find(|p| (p.3 - mass_shift).abs() <= mass_gate)?;
168    let n = by_run.len();
169    let compatible = by_run
170        .iter()
171        .filter(|p| p.2 <= 9.0 && (p.3 - mass_shift).abs() <= mass_gate)
172        .count();
173    let (mut mass, mut rt, mut devs, mut quality, mut isotopes, mut scans) = (
174        Vec::new(),
175        Vec::new(),
176        Vec::new(),
177        Vec::new(),
178        Vec::new(),
179        Vec::new(),
180    );
181    for p in &by_run {
182        let f = &observations[p.1].feature;
183        mass.push(p.3 - mass_shift);
184        rt.push(p.4);
185        devs.push(p.2.min(100.0));
186        quality.push(f.combined_score);
187        isotopes.push(f.n_isotopes.unwrap_or(0.0));
188        scans.push(f.n_scans.unwrap_or(0.0));
189    }
190    let mm = med(&mut mass);
191    let rm = med(&mut rt);
192    for x in &mut mass {
193        *x = (*x - mm).abs();
194    }
195    for x in &mut rt {
196        *x = (*x - rm).abs();
197    }
198    Some(Evidence {
199        best: best.1,
200        mass_error: best.3,
201        rt_error: best.4,
202        deviation: best.2,
203        values: [
204            (n as f64).ln_1p() as f32,
205            (compatible as f64).ln_1p() as f32,
206            compatible as f32 / n as f32,
207            by_run.get(1).map_or(100.0, |p| p.2.min(100.0)) as f32,
208            med(&mut devs) as f32,
209            mm as f32,
210            med(&mut mass) as f32,
211            rm as f32,
212            med(&mut rt) as f32,
213            med(&mut quality) as f32,
214            med(&mut isotopes).ln_1p() as f32,
215            med(&mut scans).ln_1p() as f32,
216            observations[best.1].feature.combined_score as f32,
217            (n > 1) as u8 as f32,
218        ],
219    })
220}
221
222/// Joint deviation selection does not multiply replicate likelihoods. The ML
223/// stage learns how agreement affects ranking using held-out peptide folds.
224pub fn best_deviation(
225    observations: &[Observation],
226    peptide_mass: f64,
227    predicted_rt: f64,
228    mass_shift: f64,
229    mass_sigma: f64,
230    rt_sigma: f64,
231    mass_gate: f64,
232) -> Option<f64> {
233    best_match(
234        observations,
235        peptide_mass,
236        predicted_rt,
237        mass_shift,
238        mass_sigma,
239        rt_sigma,
240        mass_gate,
241    )
242    .map(|m| m.deviation)
243}
244
245pub fn best_match(
246    observations: &[Observation],
247    peptide_mass: f64,
248    predicted_rt: f64,
249    mass_shift: f64,
250    mass_sigma: f64,
251    rt_sigma: f64,
252    mass_gate: f64,
253) -> Option<Match> {
254    observations
255        .iter()
256        .enumerate()
257        .filter_map(|(i, o)| {
258            let err = (peptide_mass - o.feature.mass) / o.feature.mass * 1e6;
259            let rt = o.feature.rt - predicted_rt;
260            ((err - mass_shift).abs() <= mass_gate).then(|| Match {
261                best: i,
262                mass_error: err,
263                rt_error: rt,
264                deviation: ((err - mass_shift) / mass_sigma).powi(2) + (rt / rt_sigma).powi(2),
265            })
266        })
267        .min_by(|a, b| {
268            a.deviation
269                .total_cmp(&b.deviation)
270                .then_with(|| {
271                    observations[b.best]
272                        .feature
273                        .combined_score
274                        .total_cmp(&observations[a.best].feature.combined_score)
275                })
276                .then_with(|| {
277                    (
278                        observations[a.best].run_id,
279                        observations[a.best].observation_id,
280                    )
281                        .cmp(&(
282                            observations[b.best].run_id,
283                            observations[b.best].observation_id,
284                        ))
285                })
286        })
287}
288
289/// One mass-compatible observed RT per candidate for cohort recalibration.
290/// RT is taken from an actual member, never from an unrelated representative.
291pub fn calibration_rt(
292    observations: &[Observation],
293    peptide_mass: f64,
294    predicted_rt: f64,
295    mass_shift: f64,
296    mass_gate: f64,
297) -> Option<f64> {
298    observations
299        .iter()
300        .filter(|o| {
301            ((peptide_mass - o.feature.mass) / o.feature.mass * 1e6 - mass_shift).abs() <= mass_gate
302        })
303        .min_by(|a, b| {
304            (a.feature.rt - predicted_rt)
305                .abs()
306                .total_cmp(&(b.feature.rt - predicted_rt).abs())
307                .then(a.feature.rt.total_cmp(&b.feature.rt))
308        })
309        .map(|o| o.feature.rt)
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    fn obs(run: usize, mass: f64, rt: f64) -> Observation {
316        Observation {
317            bootstrap_eligible: true,
318            run_id: run,
319            observation_id: 0,
320            feature: Feature {
321                mass,
322                mz: mass / 2.0 + 1.007276466621,
323                charge: 2,
324                rt,
325                im: None,
326                combined_score: 0.9,
327                n_contributing_runs: 1,
328                n_isotopes: Some(3.0),
329                n_scans: Some(10.0),
330                isoerror: None,
331                isoerror2: None,
332                rt_width: None,
333                cosine_score: Some(0.9),
334                isotope_score: Some(0.9),
335                ppm_error: None,
336                neutron_offset: None,
337                intensities: vec![100.0],
338            },
339        }
340    }
341    fn ev(o: &[Observation]) -> Evidence {
342        evidence(o, 1000.0, 10.0, 0.0, 1.0, 1.0, 3.0).unwrap()
343    }
344    #[test]
345    fn alternatives_and_duplicate_sources_do_not_inflate_support() {
346        let a = obs(0, 1000.0, 10.0);
347        let b = obs(1, 1000.0005, 10.1);
348        let base = ev(&[a.clone(), b.clone()]);
349        let copies = ev(&[a.clone(), obs(0, 1000.0, 12.0), b, a]);
350        assert_eq!(base.values, copies.values);
351        assert_eq!(base.deviation, copies.deviation);
352    }
353    #[test]
354    fn candidate_evidence_changes_when_an_additional_run_disagrees() {
355        let good = ev(&[obs(0, 1000.0, 10.0), obs(1, 1000.0005, 10.1)]);
356        let bad = ev(&[obs(0, 1000.0, 10.0), obs(1, 1000.006, 13.0)]);
357        assert_eq!(good.deviation, bad.deviation); // strong first run retained
358        assert!(good.values[2] > bad.values[2]);
359        assert!(good.values[4] < bad.values[4]);
360    }
361    #[test]
362    fn mass_and_rt_must_come_from_the_same_observation() {
363        let o = [obs(0, 1000.0, 20.0), obs(0, 1000.01, 10.0)];
364        assert!(best_deviation(&o, 1000.0, 10.0, 0.0, 1.0, 1.0, 3.0).unwrap() > 90.0);
365        assert!(ev(&o).deviation > 90.0);
366    }
367    #[test]
368    fn singleton_is_valid_and_order_does_not_change_summary() {
369        assert_eq!(ev(&[obs(0, 1000.0, 10.0)]).values[13], 0.0);
370        let a = obs(0, 1000.0, 10.0);
371        let b = obs(1, 1000.0005, 10.1);
372        assert_eq!(ev(&[a.clone(), b.clone()]).values, ev(&[b, a]).values);
373    }
374    #[test]
375    fn calibration_uses_the_mass_compatible_original_observation() {
376        let o = [obs(0, 1000.01, 10.0), obs(1, 1000.0, 10.2)];
377        assert_eq!(calibration_rt(&o, 1000.0, 10.0, 0.0, 3.0), Some(10.2));
378        assert_eq!(calibration_rt(&o[..1], 1000.0, 10.0, 0.0, 3.0), None);
379    }
380}