Skip to main content

koth_core/scoring/
elements.rs

1//! Per-element isotopologue distribution cache.
2//!
3//! For each of the five pattern-bearing elements (C, H, N, O, S), pre-compute
4//! the exact isotopologue distribution for atom counts `0..=MAX`. Phosphorus is
5//! absent on purpose: ³¹P is the only stable phosphorus isotope, so it cannot
6//! shift a pattern — see [`super::model`]. At lookup time,
7//! we convolve the five cached distributions for a given `(n_C, n_H, n_N,
8//! n_O, n_S)` to get the molecule's isotope pattern.
9//!
10//! This replaces the older per-mass averagine template table:
11//!   - flexible: hold C/H/N/O fixed, vary S → sulfur-aware scoring is one knob
12//!   - accurate: no 50 Da mass bins
13//!   - cheap: ~82 kB cache, ~4 convolutions per lookup (~1 µs)
14//!
15//! The five element distributions are exact (NOT Poisson approximations) —
16//! built by incrementally convolving the single-atom distribution.
17
18use std::sync::OnceLock;
19
20/// First slot in the isotope-pattern vector that we care about.
21/// All distributions are kept at length `K_PATTERN` (= 10 isotope peaks).
22pub const K_PATTERN: usize = 10;
23
24/// Upper bounds on element counts the cache supports. Anything above gets
25/// silently clamped. Sized to cover any tryptic peptide up to ~5050 Da
26/// with room for cysteine-rich edge cases (high S count).
27const MAX_C: usize = 300;
28const MAX_H: usize = 500;
29/// 220 covers a 9 kDa oligonucleotide (RNA averagine: ~105 N at that mass) as
30/// well as any peptide in range.
31const MAX_N: usize = 220;
32/// Oligonucleotides are oxygen-rich — the RNA averagine carries 7 O per 321 Da
33/// residue against a peptide's 1.48 per 111 Da — so a 5 kDa RNA needs 109 O and
34/// a 9 kDa one 196. At the former cap of 100 those clamped silently, which is a
35/// wrong isotope pattern rather than a slow one. The tables cost 80 bytes per
36/// count, so the headroom is free.
37const MAX_O: usize = 320;
38/// 20 covers everything biological — even a peptide of pure cysteines on a
39/// 5000 Da chain is ~50 C and 50 S; the 20-S cap is the highest reasonable
40/// bound we'd ever need for tryptic peptides. Clamped above this.
41const MAX_S: usize = 20;
42
43/// Single-atom isotope distributions (mass-offset slot → abundance).
44/// Index `i` corresponds to mass shift `+i Da` from the monoisotopic peak.
45///
46/// Sources: IUPAC isotopic compositions for natural terrestrial elements.
47const SINGLE_C: [f64; K_PATTERN] = [0.9893, 0.0107, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
48const SINGLE_H: [f64; K_PATTERN] = [0.999885, 0.000115, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
49const SINGLE_N: [f64; K_PATTERN] = [0.99632, 0.00368, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
50const SINGLE_O: [f64; K_PATTERN] = [0.99757, 0.00038, 0.00205, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
51// S has isotopes at offsets 0, +1, +2, +4 Da (no +3 — that slot is zero).
52const SINGLE_S: [f64; K_PATTERN] = [0.9499, 0.0075, 0.0425, 0.0, 0.0001, 0.0, 0.0, 0.0, 0.0, 0.0];
53
54/// Cache of per-element distributions for atom counts `0..=max_n`.
55pub struct ElementCache {
56    c: Vec<[f64; K_PATTERN]>,
57    h: Vec<[f64; K_PATTERN]>,
58    n: Vec<[f64; K_PATTERN]>,
59    o: Vec<[f64; K_PATTERN]>,
60    s: Vec<[f64; K_PATTERN]>,
61}
62
63impl ElementCache {
64    fn build() -> Self {
65        Self {
66            c: build_element_table(&SINGLE_C, MAX_C),
67            h: build_element_table(&SINGLE_H, MAX_H),
68            n: build_element_table(&SINGLE_N, MAX_N),
69            o: build_element_table(&SINGLE_O, MAX_O),
70            s: build_element_table(&SINGLE_S, MAX_S),
71        }
72    }
73
74    /// Convolve the five element distributions for the given atom counts.
75    /// Out-of-range counts are clamped to the table's max.
76    pub fn distribution(
77        &self,
78        n_c: u32,
79        n_h: u32,
80        n_n: u32,
81        n_o: u32,
82        n_s: u32,
83    ) -> [f64; K_PATTERN] {
84        let c = &self.c[(n_c as usize).min(MAX_C)];
85        let h = &self.h[(n_h as usize).min(MAX_H)];
86        let n = &self.n[(n_n as usize).min(MAX_N)];
87        let o = &self.o[(n_o as usize).min(MAX_O)];
88        let s = &self.s[(n_s as usize).min(MAX_S)];
89        let mut d = convolve_k(c, h);
90        d = convolve_k(&d, n);
91        d = convolve_k(&d, o);
92        d = convolve_k(&d, s);
93        normalize_in_place(&mut d);
94        d
95    }
96}
97
98/// Build the per-element cache: `out[n]` = distribution for `n` atoms of the element.
99fn build_element_table(single: &[f64; K_PATTERN], max_n: usize) -> Vec<[f64; K_PATTERN]> {
100    let mut table = Vec::with_capacity(max_n + 1);
101    let mut zero = [0.0f64; K_PATTERN];
102    zero[0] = 1.0;
103    table.push(zero);
104    for n in 1..=max_n {
105        table.push(convolve_k(&table[n - 1], single));
106    }
107    table
108}
109
110/// Fixed-length convolution: out[i+j] += a[i]*b[j], truncated at K_PATTERN.
111fn convolve_k(a: &[f64; K_PATTERN], b: &[f64; K_PATTERN]) -> [f64; K_PATTERN] {
112    let mut out = [0.0f64; K_PATTERN];
113    for (i, &av) in a.iter().enumerate() {
114        if av == 0.0 {
115            continue;
116        }
117        for (j, &bv) in b.iter().enumerate() {
118            if i + j >= K_PATTERN {
119                break;
120            }
121            out[i + j] += av * bv;
122        }
123    }
124    out
125}
126
127fn normalize_in_place(v: &mut [f64; K_PATTERN]) {
128    let sum: f64 = v.iter().sum();
129    if sum > 0.0 {
130        for x in v.iter_mut() {
131            *x /= sum;
132        }
133    }
134}
135
136/// Global singleton — built once on first access.
137pub fn cache() -> &'static ElementCache {
138    static CACHE: OnceLock<ElementCache> = OnceLock::new();
139    CACHE.get_or_init(ElementCache::build)
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn single_atom_matches_input() {
148        let c = cache();
149        // 1 carbon should match SINGLE_C exactly.
150        let d = c.distribution(1, 0, 0, 0, 0);
151        for i in 0..K_PATTERN {
152            assert!(
153                (d[i] - SINGLE_C[i]).abs() < 1e-12,
154                "1 C: pos {i}, got {}",
155                d[i]
156            );
157        }
158    }
159
160    #[test]
161    fn zero_atoms_is_delta() {
162        let c = cache();
163        let d = c.distribution(0, 0, 0, 0, 0);
164        assert!((d[0] - 1.0).abs() < 1e-12);
165        for i in 1..K_PATTERN {
166            assert_eq!(d[i], 0.0);
167        }
168    }
169
170    #[test]
171    fn distribution_sums_to_one() {
172        let c = cache();
173        // Random-ish averagine-shape composition for a ~1500 Da peptide.
174        let d = c.distribution(67, 105, 18, 20, 1);
175        let sum: f64 = d.iter().sum();
176        assert!((sum - 1.0).abs() < 1e-9, "sum = {sum}");
177    }
178
179    #[test]
180    fn sulfur_lifts_m_plus_two() {
181        let c = cache();
182        // Hold C/H/N/O fixed for a ~1500 Da peptide; vary S.
183        let no_s = c.distribution(67, 105, 18, 20, 0);
184        let two_s = c.distribution(67, 105, 18, 20, 2);
185        // S has 4.25% +2 abundance; 2 sulfurs should lift M+2 measurably
186        // (~+8 pp on the raw spectrum, less after re-normalization).
187        // ³⁴S is 4.25% per atom; for a ~1500 Da peptide the C13/C13 and O18
188        // contributions already dominate M+2, so a 2-S override lifts M+2 by
189        // ~15% (relative) — small but unambiguous and the right direction.
190        assert!(
191            two_s[2] > no_s[2] * 1.10,
192            "2-S M+2 ({}) should be at least 10% > 0-S M+2 ({})",
193            two_s[2],
194            no_s[2],
195        );
196    }
197
198    #[test]
199    fn two_carbons_is_convolution_of_singles() {
200        let c = cache();
201        let one_c = c.distribution(1, 0, 0, 0, 0);
202        let two_c = c.distribution(2, 0, 0, 0, 0);
203        // Convolve one_c with itself manually
204        let mut expected = [0.0f64; K_PATTERN];
205        for i in 0..K_PATTERN {
206            for j in 0..K_PATTERN - i {
207                expected[i + j] += one_c[i] * one_c[j];
208            }
209        }
210        // Normalize for comparison (in case of tiny float drift)
211        let s: f64 = expected.iter().sum();
212        for x in &mut expected {
213            *x /= s;
214        }
215        for i in 0..K_PATTERN {
216            assert!(
217                (two_c[i] - expected[i]).abs() < 1e-12,
218                "pos {i}: {} vs {}",
219                two_c[i],
220                expected[i]
221            );
222        }
223    }
224}