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}