1use crate::rt::rt_features;
3use anyhow::Result;
4use gbdt::decision_tree::Data;
5use gbdt::gradient_boost::GBDT;
6use std::path::Path;
7
8pub const DEFAULT_IM_CNN: &[u8] = include_bytes!("../models/im_cnn_public_20260914.uimc");
10
11pub const IM_N_FEATURES: usize = crate::rt::RT_N_FEATURES + 1;
13
14pub fn im_features(seq: &str, charge: u8) -> Vec<f64> {
15 let mut v = rt_features(seq);
16 v.push(charge as f64);
17 v
18}
19
20fn to_f32(v: &[f64]) -> Vec<f32> {
21 v.iter().map(|&x| x as f32).collect()
22}
23
24enum ImBackend {
26 Cnn(crate::rt_cnn::RtCnn),
27 Gbdt(GBDT),
28 Sequoia(Box<sequoia_boost::prelude::BoostedModel>),
29}
30
31pub struct ImModel {
32 backend: ImBackend,
33 fingerprint: String,
34 cache: std::sync::Mutex<crate::im_cache::PredictionCache>,
35}
36
37impl ImModel {
38 fn from_backend(backend: ImBackend, bytes: &[u8]) -> Self {
39 use sha2::{Digest, Sha256};
40 let mut hash = Sha256::new();
41 hash.update(b"uno-im-prediction-v1\0");
43 hash.update(bytes);
44 let fingerprint = format!("{:x}", hash.finalize());
45 let cache = std::sync::Mutex::new(crate::im_cache::PredictionCache::new(&fingerprint));
46 Self {
47 backend,
48 fingerprint,
49 cache,
50 }
51 }
52
53 pub fn fingerprint(&self) -> &str {
54 &self.fingerprint
55 }
56
57 pub fn bundled() -> Result<Self> {
59 Ok(Self::from_backend(
60 ImBackend::Cnn(crate::rt_cnn::RtCnn::from_im_bytes(DEFAULT_IM_CNN)?),
61 DEFAULT_IM_CNN,
62 ))
63 }
64
65 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
67 let path = path.as_ref();
68 let bytes = std::fs::read(path)?;
69 if bytes.starts_with(b"UIMC") {
70 return Ok(Self::from_backend(
71 ImBackend::Cnn(crate::rt_cnn::RtCnn::from_im_bytes(&bytes)?),
72 &bytes,
73 ));
74 }
75 if let Ok(m) = sequoia_boost::prelude::BoostedModel::load_binary(path) {
76 anyhow::ensure!(m.n_features() == IM_N_FEATURES,
77 "IM model expects {} features, current encoder supplies {}. Retrain with the current encoder; do not silently reinterpret an old schema.", m.n_features(), IM_N_FEATURES);
78 return Ok(Self::from_backend(ImBackend::Sequoia(Box::new(m)), &bytes));
79 }
80 let metadata: serde_json::Value = serde_json::from_slice(&std::fs::read(path)?)?;
81 let width = metadata["conf"]["feature_size"]
82 .as_u64()
83 .ok_or_else(|| anyhow::anyhow!("IM GBDT has no feature-size metadata"))?
84 as usize;
85 anyhow::ensure!(width == IM_N_FEATURES,
86 "IM model expects {} features, current encoder supplies {}. Retrain with the current encoder; do not silently reinterpret an old schema.", width, IM_N_FEATURES);
87 let model = GBDT::load_model(&path.to_string_lossy())
88 .map_err(|e| anyhow::anyhow!("loading im model {}: {e}", path.display()))?;
89 Ok(Self::from_backend(ImBackend::Gbdt(model), &bytes))
90 }
91
92 pub fn cnn_info(&self) -> Option<&serde_json::Value> {
93 match &self.backend {
94 ImBackend::Cnn(m) => Some(m.info()),
95 _ => None,
96 }
97 }
98
99 pub fn predict_many_cached(&self, items: &[(&str, u8)]) -> Vec<f64> {
101 let t = std::time::Instant::now();
102 let (values, misses) = self
103 .cache
104 .lock()
105 .unwrap()
106 .predict(items, |q| self.predict_many(q));
107 log::info!(
108 "IM predictions: {} unique misses of {} requested ({:.3}s)",
109 misses,
110 items.len(),
111 t.elapsed().as_secs_f64()
112 );
113 values
114 }
115
116 pub fn precompute_library(&self, seqs: &[&str], charges: &[u8]) {
119 if seqs.is_empty() || charges.is_empty() {
120 return;
121 }
122 let t = std::time::Instant::now();
123 let mut cache = self.cache.lock().unwrap();
124 if !cache.enabled {
126 return;
127 }
128 let mut seqs = seqs.to_vec();
129 seqs.sort_unstable();
130 seqs.dedup();
131 let mut charges = charges.to_vec();
132 charges.retain(|&z| z > 0);
133 charges.sort_unstable();
134 charges.dedup();
135 let mut misses = 0;
136 for &z in &charges {
137 for chunk in seqs.chunks(crate::im_cache::BATCH_SIZE) {
138 let queries: Vec<_> = chunk.iter().map(|&s| (s, z)).collect();
139 misses += cache.predict(&queries, |q| self.predict_many(q)).1;
140 }
141 }
142 log::info!(
143 "IM library cache: {} sequences x {} charges = {} pairs; {} unique misses ({:.3}s)",
144 seqs.len(),
145 charges.len(),
146 seqs.len() * charges.len(),
147 misses,
148 t.elapsed().as_secs_f64()
149 );
150 }
151
152 pub fn predict_many(&self, items: &[(&str, u8)]) -> Vec<f64> {
155 let mut lookup = std::collections::HashMap::new();
156 let mut unique = Vec::new();
157 let mut indices = Vec::with_capacity(items.len());
158 for &item in items {
159 let idx = *lookup.entry(item).or_insert_with(|| {
160 unique.push(item);
161 unique.len() - 1
162 });
163 indices.push(idx);
164 }
165 let mut predictions = Vec::with_capacity(unique.len());
166 for chunk in unique.chunks(16_384) {
167 predictions.extend(self.predict_distinct(chunk));
168 }
169 indices.into_iter().map(|i| predictions[i]).collect()
170 }
171
172 fn predict_distinct(&self, items: &[(&str, u8)]) -> Vec<f64> {
173 match &self.backend {
174 ImBackend::Cnn(model) => model.predict_im_many(items),
175 ImBackend::Gbdt(model) => {
176 let data: Vec<Data> = items
177 .iter()
178 .map(|&(s, z)| Data::new_test_data(to_f32(&im_features(s, z)), None))
179 .collect();
180 model.predict(&data).into_iter().map(|v| v as f64).collect()
181 }
182 ImBackend::Sequoia(model) => {
183 use sequoia_boost::prelude::*;
184 let mut flat = Vec::with_capacity(items.len() * IM_N_FEATURES);
185 for &(s, z) in items {
186 flat.extend_from_slice(&to_f32(&im_features(s, z)));
187 }
188 let matrix = DMatrix::from_dense(&flat, items.len(), IM_N_FEATURES)
189 .expect("validated IM feature matrix dimensions");
190 model
191 .predict(&matrix)
192 .expect("validated IM model dimensions")
193 .into_iter()
194 .map(|v| v as f64)
195 .collect()
196 }
197 }
198 }
199}
200
201pub fn read_im2deep_csv(path: impl AsRef<Path>) -> Result<(Vec<String>, Vec<u8>, Vec<f64>)> {
204 let mut rdr = csv::ReaderBuilder::new()
205 .has_headers(true)
206 .flexible(true)
207 .from_path(path.as_ref())?;
208 let (mut seqs, mut charges, mut ims) = (Vec::new(), Vec::new(), Vec::new());
209 for rec in rdr.records() {
210 let rec = rec?;
211 let modseq = match rec.get(0) {
212 Some(s) => s,
213 None => continue,
214 };
215 let seq = modseq.split('/').next().unwrap_or("").to_string();
216 let charge: u8 = match rec.get(1).and_then(|s| s.trim().parse().ok()) {
217 Some(z) => z,
218 None => continue,
219 };
220 let im: f64 = match rec.get(2).and_then(|s| s.trim().parse().ok()) {
221 Some(v) => v,
222 None => continue,
223 };
224 if seq.is_empty() || !seq.bytes().all(|b| b.is_ascii_uppercase()) {
225 continue;
226 }
227 seqs.push(seq);
228 charges.push(charge);
229 ims.push(im);
230 }
231 Ok((seqs, charges, ims))
232}
233
234#[cfg(test)]
235mod schema_tests {
236 use super::*;
237 #[test]
238 fn incompatible_legacy_schema_is_rejected_before_prediction() {
239 let p = std::env::temp_dir().join(format!("uno-im-schema-{}.json", std::process::id()));
240 std::fs::write(&p, r#"{"conf":{"feature_size":70}}"#).unwrap();
241 let err = ImModel::load(&p)
242 .err()
243 .expect("old feature width must fail");
244 std::fs::remove_file(p).unwrap();
245 assert!(err
246 .to_string()
247 .contains("expects 70 features, current encoder supplies 100"));
248 }
249 #[test]
250 fn incompatible_sequoia_schema_is_rejected_at_load() {
251 use sequoia_boost::prelude::*;
252 let d = DMatrix::from_dense(&[0.0, 1.0, 2.0, 3.0], 4, 1)
253 .unwrap()
254 .with_labels(&[0.0, 1.0, 2.0, 3.0])
255 .unwrap();
256 let params = TrainingParams::builder()
257 .objective("reg:squarederror")
258 .build()
259 .unwrap();
260 let model = train(¶ms, &d, 1).unwrap();
261 let p = std::env::temp_dir().join(format!("uno-im-schema-{}.sequoia", std::process::id()));
262 model.save_binary(&p).unwrap();
263 let err = ImModel::load(&p)
264 .err()
265 .expect("wrong feature width must fail");
266 std::fs::remove_file(p).unwrap();
267 assert!(err
268 .to_string()
269 .contains("expects 1 features, current encoder supplies 100"));
270 }
271 #[test]
272 fn charge_is_last_and_sequence_descriptors_are_charge_independent() {
273 let a = im_features("PEPTIDEK", 2);
274 let b = im_features("PEPTIDEK", 3);
275 assert_eq!(a.len(), IM_N_FEATURES);
276 assert_eq!(&a[..IM_N_FEATURES - 1], &b[..IM_N_FEATURES - 1]);
277 assert_eq!(a[IM_N_FEATURES - 1], 2.0);
278 assert_eq!(b[IM_N_FEATURES - 1], 3.0);
279 }
280}
281
282pub fn soft_residuals(model: &ImModel, items: &[(&str, u8, Option<f64>)]) -> Vec<Option<f64>> {
286 let valid: Vec<usize> = items
287 .iter()
288 .enumerate()
289 .filter_map(|(i, &(s, z, o))| {
290 (z > 0 && !s.is_empty() && o.is_some_and(|v| v.is_finite() && v > 0.0)).then_some(i)
291 })
292 .collect();
293 let queries: Vec<(&str, u8)> = valid.iter().map(|&i| (items[i].0, items[i].1)).collect();
294 let predictions = model.predict_many_cached(&queries);
295 let observed: Vec<(u8, f64, f64)> = valid
296 .iter()
297 .zip(&predictions)
298 .map(|(&i, &p)| (items[i].1, items[i].2.unwrap(), p))
299 .collect();
300 let scaled = calibrated_residuals(&observed);
301 let mut result = vec![None; items.len()];
302 for (&i, r) in valid.iter().zip(scaled) {
303 result[i] = r;
304 }
305 let unique = queries
306 .iter()
307 .collect::<std::collections::HashSet<_>>()
308 .len();
309 log::info!("IM soft feature: {} unique sequence/charge predictions for {} RT-shortlisted candidates; {} calibrated, no IM rejection",
310 unique, items.len(), result.iter().filter(|r| r.is_some()).count());
311 result
312}
313
314fn calibrated_residuals(items: &[(u8, f64, f64)]) -> Vec<Option<f64>> {
315 let mut ratios: std::collections::BTreeMap<u8, Vec<f64>> = Default::default();
316 for &(z, o, p) in items {
317 if z > 0 && o.is_finite() && o > 0.0 && p.is_finite() && p > 0.0 {
318 ratios.entry(z).or_default().push(o / p);
319 }
320 }
321 let scales: std::collections::BTreeMap<u8, f64> = ratios
322 .into_iter()
323 .filter_map(|(z, mut v)| {
324 if v.len() < 20 {
326 return None;
327 }
328 v.sort_by(f64::total_cmp);
329 let n = v.len();
330 let median = (v[(n - 1) / 2] + v[n / 2]) / 2.0;
331 (median.is_finite() && median > 0.0).then_some((z, median))
332 })
333 .collect();
334 items
335 .iter()
336 .map(|&(z, o, p)| {
337 if !(o.is_finite() && o > 0.0 && p.is_finite() && p > 0.0) {
338 return None;
339 }
340 scales.get(&z).and_then(|s| {
341 let residual = (o - p * s) / o;
342 residual.is_finite().then_some(residual)
343 })
344 })
345 .collect()
346}
347
348#[cfg(test)]
349mod soft_tests {
350 use super::*;
351 #[test]
352 fn full_library_cache_preserves_predictions_and_recalibrates_each_shortlist() {
353 let dir = std::env::temp_dir().join(format!(
354 "uno-im-library-{}-{}",
355 std::process::id(),
356 std::time::SystemTime::now()
357 .duration_since(std::time::UNIX_EPOCH)
358 .unwrap()
359 .as_nanos()
360 ));
361 let mut model = ImModel::bundled().unwrap();
362 let path = dir.join(model.fingerprint());
363 model.cache = std::sync::Mutex::new(crate::im_cache::PredictionCache::isolated(
364 path.clone(),
365 true,
366 ));
367 let queries = [
368 ("PEPTIDEK", 2),
369 ("AAAAAAAR", 3),
370 ("PEPTIDEK", 3),
371 ("AAAAAAAR", 2),
372 ];
373 let direct = model.predict_many(&queries);
374 model.precompute_library(&["PEPTIDEK", "AAAAAAAR", "PEPTIDEK"], &[2, 3, 2, 0]);
375 let bytes = crate::im_cache::tests::disk_snapshot(&path);
376 model.backend = ImBackend::Gbdt(GBDT::new(&gbdt::config::Config::new()));
378 let cached = model.predict_many_cached(&queries);
379 assert_eq!(
380 cached.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
381 direct.iter().map(|v| v.to_bits()).collect::<Vec<_>>()
382 );
383 let mut reloaded = ImModel::bundled().unwrap();
384 reloaded.cache = std::sync::Mutex::new(crate::im_cache::PredictionCache::isolated(
385 path.clone(),
386 true,
387 ));
388 reloaded.backend = ImBackend::Gbdt(GBDT::new(&gbdt::config::Config::new()));
389 assert_eq!(reloaded.predict_many_cached(&queries), direct);
390 for scale in [2.0, 3.0] {
391 let rows: Vec<_> = (0..30)
392 .flat_map(|_| {
393 queries
394 .iter()
395 .zip(&direct)
396 .map(|(&(s, z), &p)| (s, z, Some(p * scale)))
397 })
398 .collect();
399 let expected: Vec<_> = (0..30)
400 .flat_map(|_| {
401 queries
402 .iter()
403 .zip(&direct)
404 .map(|(&(_, z), &p)| (z, p * scale, p))
405 })
406 .collect();
407 assert_eq!(
408 soft_residuals(&reloaded, &rows),
409 calibrated_residuals(&expected)
410 );
411 }
412 assert_eq!(crate::im_cache::tests::disk_snapshot(&path), bytes);
413 std::fs::remove_dir_all(dir).unwrap();
414 }
415
416 #[test]
417 fn model_content_controls_cache_identity() {
418 let a = ImModel::bundled().unwrap();
419 let same = ImModel::bundled().unwrap();
420 assert_eq!(a.fingerprint(), same.fingerprint());
421 let different = ImModel::from_backend(
422 ImBackend::Gbdt(GBDT::new(&gbdt::config::Config::new())),
423 b"different-weights",
424 );
425 assert_ne!(a.fingerprint(), different.fingerprint());
426 }
427
428 #[test]
429 fn bundled_cnn_is_portable_and_charge_aware() {
430 let m = ImModel::bundled().unwrap();
431 assert_eq!(m.cnn_info().unwrap()["format"], "UIMC");
432 let p = m.predict_many(&[("PEPTIDEK", 2), ("PEPTIDEK", 3), ("PEPTIDEK", 2)]);
433 assert!(p.iter().all(|v| v.is_finite() && *v > 0.0));
434 assert_eq!(p[0], p[2]);
435 assert!((p[0] - p[1]).abs() > 0.01);
436 }
437 #[test]
438 fn missing_im_never_invokes_the_untrained_predictor() {
439 let model = ImModel::from_backend(
440 ImBackend::Gbdt(GBDT::new(&gbdt::config::Config::new())),
441 b"untrained-test",
442 );
443 let rows = [
444 ("PEPTIDEK", 2, None),
445 ("PEPTIDER", 3, Some(f64::NAN)),
446 ("AAAAK", 2, Some(0.0)),
447 ];
448 assert_eq!(soft_residuals(&model, &rows), vec![None, None, None]);
449 }
450 #[test]
451 fn calibration_is_charge_specific_and_permutation_equivariant() {
452 let mut items = vec![(2, 2.0, 1.0); 30];
453 items.extend(vec![(3, 3.0, 1.0); 30]);
454 items.extend([
455 (2, 4.0, 1.0),
456 (4, 1.0, 1.0),
457 (2, f64::NAN, 1.0),
458 (2, 2.0, -1.0),
459 ]);
460 let r = calibrated_residuals(&items);
461 assert_eq!(r[0], Some(0.0));
462 assert_eq!(r[30], Some(0.0));
463 assert_eq!(r[60], Some(0.5));
464 assert_eq!(&r[61..], &[None, None, None]);
465 items.reverse();
466 let mut reverse = calibrated_residuals(&items);
467 reverse.reverse();
468 assert_eq!(r, reverse);
469 }
470}