1use crate::mass;
13use anyhow::{Context, Result};
14use gbdt::decision_tree::Data;
15use gbdt::gradient_boost::GBDT;
16use std::path::Path;
17
18const AA: &[u8; 20] = b"ACDEFGHIKLMNPQRSTVWY";
19const N_POS_BINS: usize = 3;
20
21pub const RT_N_FEATURES: usize = 20 + 6 + N_POS_BINS * 20 + 13;
28
29const KD: [f64; 20] = [
33 1.8, 2.5, -3.5, -3.5, 2.8, -0.4, -3.2, 4.5, -3.9, 3.8, 1.9, -3.5, -1.6, -3.5, -4.5, -0.8, -0.7,
34 4.2, -0.9, -1.3,
35];
36const RC: [f64; 20] = [
37 1.1, 0.5, 0.2, 1.1, 10.9, 0.2, -3.1, 8.0, -3.1, 9.6, 5.0, -1.2, 2.1, -2.0, -4.1, -2.1, 0.6,
38 5.5, 11.1, 4.4,
39];
40const EI: [f64; 20] = [
41 0.62, 0.29, -0.90, -0.74, 1.19, 0.48, -0.40, 1.38, -1.50, 1.06, 0.64, -0.78, 0.12, -0.85,
42 -2.53, -0.18, -0.05, 1.08, 0.81, 0.26,
43];
44
45fn aa_index(c: u8) -> Option<usize> {
46 AA.iter().position(|&a| a == c)
47}
48
49fn peptide_mass(seq: &[u8]) -> f64 {
50 let mut m = mass::H2O;
51 for &c in seq {
52 m += mass::residue(c).unwrap_or(0.0);
53 }
54 m
55}
56
57fn split_bound(n: usize, k: usize, i: usize) -> usize {
60 let (base, rem) = (n / k, n % k);
61 i * base + i.min(rem)
62}
63
64pub fn rt_features(seq: &str) -> Vec<f64> {
68 let bytes = seq.as_bytes();
69 let idx: Vec<usize> = bytes.iter().filter_map(|&c| aa_index(c)).collect();
70 let n = idx.len();
71 let mut v = vec![0.0f64; RT_N_FEATURES];
72
73 for &j in &idx {
75 v[j] += 1.0;
76 }
77 for k in 0..3 {
79 v[20 + k] = if k < n { idx[k] as f64 } else { -1.0 };
80 v[23 + k] = if k < n { idx[n - 1 - k] as f64 } else { -1.0 };
81 }
82 let pb = 26;
84 for b in 0..N_POS_BINS {
85 let (lo, hi) = (
86 split_bound(n, N_POS_BINS, b),
87 split_bound(n, N_POS_BINS, b + 1),
88 );
89 for &j in &idx[lo..hi] {
90 v[pb + b * 20 + j] += 1.0;
91 }
92 }
93 let s = pb + N_POS_BINS * 20;
95 let nf = n.max(1) as f64;
96 let (mut sum_kd, mut sum_rc, mut sum_ei) = (0.0, 0.0, 0.0);
97 let (mut max_kd, mut min_kd) = (f64::NEG_INFINITY, f64::INFINITY);
98 let (mut ssr, mut moment) = (0.0, 0.0);
99 let (mut p_ct, mut arom, mut kr) = (0.0, 0.0, 0.0);
100 for (k, &j) in idx.iter().enumerate() {
101 sum_kd += KD[j];
102 sum_rc += RC[j];
103 sum_ei += EI[j];
104 max_kd = max_kd.max(KD[j]);
105 min_kd = min_kd.min(KD[j]);
106 ssr += RC[j] * (1.0 + if k < 3 { 0.3 } else { 0.0 });
107 moment += KD[j]
108 * if n > 1 {
109 k as f64 / (n - 1) as f64
110 } else {
111 0.0
112 };
113 match AA[j] {
114 b'P' => p_ct += 1.0,
115 b'F' | b'W' | b'Y' => arom += 1.0,
116 b'K' | b'R' => kr += 1.0,
117 _ => {}
118 }
119 }
120 if n == 0 {
121 max_kd = 0.0;
122 min_kd = 0.0;
123 }
124 v[s] = n as f64;
125 v[s + 1] = peptide_mass(bytes).ln_1p();
126 v[s + 2] = sum_kd;
127 v[s + 3] = sum_kd / nf;
128 v[s + 4] = max_kd;
129 v[s + 5] = min_kd;
130 v[s + 6] = sum_rc;
131 v[s + 7] = sum_ei;
132 v[s + 8] = ssr;
133 v[s + 9] = p_ct;
134 v[s + 10] = arom;
135 v[s + 11] = kr;
136 v[s + 12] = moment;
137 v
138}
139
140fn to_f32(v: &[f64]) -> Vec<f32> {
141 v.iter().map(|&x| x as f32).collect()
142}
143
144enum RtBackend {
147 Gbdt(GBDT),
149 Mlp(crate::rt_mlp::RtMlp),
151 Cnn(crate::rt_cnn::RtCnn),
154}
155
156pub const DEFAULT_RT_CNN: &[u8] = include_bytes!("../models/rt_cnn_chrono_global_v3_ch32.urtc");
158
159pub struct RtModel {
164 backend: RtBackend,
165 fingerprint: u64,
166}
167
168impl RtModel {
169 pub fn bundled() -> Result<Self> {
171 Ok(Self {
172 backend: RtBackend::Cnn(crate::rt_cnn::RtCnn::from_bytes(DEFAULT_RT_CNN)?),
173 fingerprint: fingerprint_bytes(DEFAULT_RT_CNN),
174 })
175 }
176
177 pub fn cnn_info(&self) -> Option<&serde_json::Value> {
179 match &self.backend {
180 RtBackend::Cnn(model) => Some(model.info()),
181 _ => None,
182 }
183 }
184 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
187 let path = path.as_ref();
188 let bytes = std::fs::read(path)
189 .map_err(|e| anyhow::anyhow!("opening rt model {}: {e}", path.display()))?;
190 let fingerprint = fingerprint_bytes(&bytes);
191 let magic = bytes.get(0..4).unwrap_or(&[]);
192 let backend = if magic == b"UMLP" {
193 RtBackend::Mlp(crate::rt_mlp::RtMlp::load(path)?)
194 } else if magic == b"URTC" {
195 RtBackend::Cnn(crate::rt_cnn::RtCnn::from_bytes(&bytes)?)
196 } else {
197 let model = GBDT::load_model(&path.to_string_lossy())
198 .map_err(|e| anyhow::anyhow!("loading rt model {}: {e}", path.display()))?;
199 RtBackend::Gbdt(model)
200 };
201 if !matches!(&backend, RtBackend::Cnn(_)) {
202 log::warn!(
203 "legacy RT backend: MLP/GBDT loading is deprecated; use a URTC CNN or --rt-library"
204 );
205 }
206 Ok(RtModel {
207 backend,
208 fingerprint,
209 })
210 }
211
212 pub fn fingerprint(&self) -> u64 {
214 self.fingerprint
215 }
216
217 pub fn predict_seq(&self, seq: &str) -> f64 {
219 match &self.backend {
220 RtBackend::Gbdt(model) => {
221 let data = vec![Data::new_test_data(to_f32(&rt_features(seq)), None)];
222 model.predict(&data)[0] as f64
223 }
224 RtBackend::Mlp(m) => m.predict_seq(seq),
225 RtBackend::Cnn(m) => m.predict_seq(seq),
226 }
227 }
228
229 pub fn embed_many<S: AsRef<str> + Sync>(&self, seqs: &[S]) -> Option<Vec<Vec<f64>>> {
232 match &self.backend {
233 RtBackend::Mlp(m) => Some(m.embed_many(seqs)),
234 RtBackend::Cnn(m) => Some(m.embed_many(seqs)),
235 RtBackend::Gbdt(_) => None,
236 }
237 }
238
239 pub fn predict_embed_many<S: AsRef<str> + Sync>(
246 &self,
247 seqs: &[S],
248 ) -> (Vec<f64>, Option<Vec<Vec<f64>>>) {
249 match &self.backend {
250 RtBackend::Cnn(m) => {
251 let (p, e) = m.predict_embed_many(seqs);
252 (p, Some(e))
253 }
254 RtBackend::Mlp(m) => (m.predict_many(seqs), Some(m.embed_many(seqs))),
257 RtBackend::Gbdt(_) => (self.predict_many(seqs), None),
258 }
259 }
260
261 pub fn predict_many<S: AsRef<str> + Sync>(&self, seqs: &[S]) -> Vec<f64> {
263 match &self.backend {
264 RtBackend::Gbdt(model) => {
265 let data: Vec<Data> = seqs
266 .iter()
267 .map(|s| Data::new_test_data(to_f32(&rt_features(s.as_ref())), None))
268 .collect();
269 model.predict(&data).into_iter().map(|v| v as f64).collect()
270 }
271 RtBackend::Mlp(m) => m.predict_many(seqs),
272 RtBackend::Cnn(m) => m.predict_many(seqs),
273 }
274 }
275}
276
277fn fingerprint_bytes(bytes: &[u8]) -> u64 {
281 let mut h: u64 = 0xcbf29ce484222325;
282 for &b in bytes {
283 h ^= b as u64;
284 h = h.wrapping_mul(0x100000001b3);
285 }
286 h
287}
288
289pub fn read_label_tsv(path: impl AsRef<Path>) -> Result<(Vec<String>, Vec<f64>)> {
292 let path = path.as_ref();
293 let mut rdr = csv::ReaderBuilder::new()
294 .delimiter(b'\t')
295 .has_headers(false)
296 .flexible(true)
297 .from_path(path)
298 .with_context(|| format!("opening label tsv {}", path.display()))?;
299
300 let mut seqs = Vec::new();
301 let mut rts = Vec::new();
302 for rec in rdr.records() {
303 let rec = rec?;
304 let seq = match rec.get(0) {
305 Some(s) if !s.is_empty() => s,
306 _ => continue,
307 };
308 let rt: f64 = match rec.get(1).and_then(|s| s.trim().parse().ok()) {
309 Some(v) => v,
310 None => continue,
311 };
312 if seq.chars().all(|c| c.is_ascii_uppercase()) {
314 seqs.push(seq.to_string());
315 rts.push(rt);
316 }
317 }
318 Ok((seqs, rts))
319}
320
321#[cfg(test)]
322mod cnn_workflow_tests {
323 use super::*;
324
325 #[test]
326 fn bundled_cnn_is_portable_and_matches_training_package() {
327 assert_eq!(
328 DEFAULT_RT_CNN,
329 include_bytes!("../../uno-rt-train/python/uno_rt/data/default.urtc")
330 );
331 let model = RtModel::bundled().unwrap();
332 let direct = crate::rt_cnn::RtCnn::from_bytes(DEFAULT_RT_CNN).unwrap();
333 assert_eq!(
334 model.predict_seq("PEPTIDEK"),
335 direct.predict_seq("PEPTIDEK")
336 );
337 assert_eq!(model.fingerprint(), fingerprint_bytes(DEFAULT_RT_CNN));
338 }
339
340 #[test]
341 fn cnn_rejects_invalid_dimensions_normalization_and_version() {
342 for (offset, value) in [(4, 99u32), (12, 0), (16, 0), (24, 2), (28, u32::MAX)] {
343 let mut bytes = DEFAULT_RT_CNN.to_vec();
344 bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
345 assert!(crate::rt_cnn::RtCnn::from_bytes(&bytes).is_err());
346 }
347 assert!(crate::rt_cnn::RtCnn::from_bytes(&DEFAULT_RT_CNN[..80]).is_err());
348 }
349}