1use crate::calibrate::Calibration;
17use crate::database::PeptideDb;
18use crate::input::{Feature, FeatureMatrix};
19use crate::matching::Pfm;
20use crate::protein::ProteinId;
21use crate::rt::RtModel;
22use rayon::prelude::*;
23use std::cmp::Ordering;
24
25fn feature_cmp(a: &Feature, b: &Feature) -> Ordering {
33 let ai: f64 = a.intensities.iter().sum();
37 let bi: f64 = b.intensities.iter().sum();
38 b.combined_score
39 .partial_cmp(&a.combined_score)
40 .unwrap()
41 .then(bi.partial_cmp(&ai).unwrap())
42 .then(
43 b.n_isotopes
44 .unwrap_or(0.0)
45 .partial_cmp(&a.n_isotopes.unwrap_or(0.0))
46 .unwrap(),
47 )
48 .then(a.mass.partial_cmp(&b.mass).unwrap())
49 .then(a.rt.partial_cmp(&b.rt).unwrap())
50 .then(a.mz.partial_cmp(&b.mz).unwrap())
51}
52
53#[derive(Debug, Clone, serde::Deserialize)]
54#[serde(default)]
55pub struct ScoreConfig {
56 pub matrix_joint_agreement: bool,
59 pub matrix_joint_cell_quality: bool,
61 pub matrix_joint_cell_counts: bool,
63 pub matrix_joint_cell_q: bool,
65 pub matrix_joint_alternative_groups: bool,
67 pub diagnostic_peptides_path: Option<String>,
69 pub matrix_recurrence: bool,
71 pub top_k: usize,
79 pub n_folds: usize,
80 pub calibration_fdr: f64,
82 pub bootstrap_rounds: usize,
84 pub iterations: usize,
85 pub max_depth: u32,
86 pub shrinkage: f32,
87 pub max_train_rows: usize,
89 pub seed_min_runs_frac: f64,
93 pub seed: u64,
97 pub rt_seed: Option<u64>,
99 pub cv_seed: Option<u64>,
101 pub classifier_seed: Option<u64>,
104 pub mass_anchor_ppm: f64,
110 pub discriminant: String,
115 pub ondata_rt: bool,
120 pub ondata_rt_lambda: f64,
122 pub ondata_rt_finetune: bool,
128 pub mass_anchor: bool,
133 pub rt_calibrated: bool,
138 pub ml_rescore: bool,
155}
156
157impl Default for ScoreConfig {
158 fn default() -> Self {
159 ScoreConfig {
160 matrix_joint_agreement: true,
161 matrix_joint_cell_quality: false,
162 matrix_joint_cell_counts: true,
163 matrix_joint_cell_q: true,
164 matrix_joint_alternative_groups: false,
165 diagnostic_peptides_path: None,
166 matrix_recurrence: true,
167 top_k: 50,
168 n_folds: 3,
169 calibration_fdr: 0.01,
170 bootstrap_rounds: 2,
171 iterations: 100,
172 max_depth: 6,
173 shrinkage: 0.1,
174 max_train_rows: 200_000,
175 seed_min_runs_frac: 0.5,
176 seed: 0,
177 rt_seed: None,
178 cv_seed: None,
179 classifier_seed: None,
180 mass_anchor_ppm: 10.0,
181 discriminant: "deviation".to_string(),
182 ondata_rt: true,
189 ondata_rt_lambda: 1.0,
190 ondata_rt_finetune: true,
191 mass_anchor: true,
192 rt_calibrated: false,
193 ml_rescore: true,
201 }
202 }
203}
204
205impl ScoreConfig {
206 pub fn effective_rt_seed(&self) -> u64 {
207 self.rt_seed.unwrap_or(self.seed)
208 }
209
210 pub fn effective_cv_seed(&self) -> u64 {
211 self.cv_seed.unwrap_or(self.seed)
212 }
213
214 pub fn effective_classifier_seed(&self) -> u64 {
215 self.classifier_seed.unwrap_or(self.seed)
216 }
217}
218
219#[derive(Debug, Clone)]
222pub struct ScoreResult {
223 pub proteins: Vec<crate::protein::ProteinId>,
224 pub peptides: Vec<PeptideHit>,
227}
228
229fn featurize(
233 pfm: &Pfm,
234 matrix: &FeatureMatrix,
235 db: &PeptideDb,
236 rt_diff: Option<f64>,
237 im_diff: Option<f64>,
238) -> Vec<f32> {
239 let f = &matrix.features[pfm.feature_idx as usize];
240 let p = &db.peptides[pfm.peptide_idx as usize];
241 let n_detected = f.intensities.iter().filter(|&&x| x > 0.0).count();
242 let sum_int: f64 = f.intensities.iter().sum();
243 let mut values = vec![
244 pfm.mass_error_ppm.abs() as f32,
245 pfm.mass_error_ppm as f32,
246 pfm.isotope_offset.unsigned_abs() as f32,
247 rt_diff.unwrap_or(0.0).abs() as f32,
248 f.combined_score as f32,
249 ((1.0 + f.n_contributing_runs as f64).ln()) as f32,
250 f.charge as f32,
251 ((1.0 + sum_int).log10()) as f32,
252 p.sequence.len() as f32,
253 p.missed_cleavages as f32,
254 n_detected as f32,
255 im_diff.unwrap_or(0.0).abs() as f32,
256 (1.0 + f.n_isotopes.unwrap_or(0.0)).ln() as f32,
261 (1.0 + f.n_scans.unwrap_or(0.0)).ln() as f32,
262 f.cosine_score.unwrap_or(0.0) as f32,
267 f.isotope_score.unwrap_or(0.0) as f32,
268 f.ppm_error.unwrap_or(0.0) as f32,
269 f.neutron_offset.unwrap_or(0.0) as f32,
270 ];
271 if let Some(evidence) = &matrix.matrix_evidence {
272 values.extend_from_slice(&evidence[pfm.feature_idx as usize]);
273 }
274 values
275}
276
277fn fold_of(feature_idx: usize, n_folds: usize, seed: u64) -> usize {
279 let mut x = (feature_idx as u64) ^ crate::seed_mix(seed);
280 x ^= x >> 30;
281 x = x.wrapping_mul(0xbf58476d1ce4e5b9);
282 x ^= x >> 27;
283 (x % n_folds as u64) as usize
284}
285
286fn median(v: &mut [f64]) -> f64 {
287 v.sort_by(|a, b| a.partial_cmp(b).unwrap());
288 let n = v.len();
289 if n == 0 {
290 return f64::NAN;
291 }
292 if n % 2 == 1 {
293 v[n / 2]
294 } else {
295 0.5 * (v[n / 2 - 1] + v[n / 2])
296 }
297}
298
299fn dev_posteriors(
307 pep_dev: &std::collections::HashMap<usize, (bool, f64, usize)>,
308 scale: f64,
309) -> rustc_hash::FxHashMap<usize, f64> {
310 let mut items: Vec<(f64, bool, usize)> = pep_dev
311 .iter()
312 .map(|(&pi, &(dc, dev, _))| (dev, dc, pi))
313 .collect();
314 items.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
315 let n = items.len();
316 if n == 0 {
317 return rustc_hash::FxHashMap::default();
318 }
319 let nbins = 256.min(n);
320 let mut bin_w = vec![0.0f64; nbins];
321 let mut bin_start = vec![0usize; nbins];
322 for b in 0..nbins {
323 let lo = b * n / nbins;
324 let hi = (((b + 1) * n / nbins).max(lo + 1)).min(n);
325 bin_start[b] = lo;
326 let (mut tgt, mut dec) = (0.0f64, 0.0f64);
327 for it in &items[lo..hi] {
328 if it.1 {
329 dec += 1.0;
330 } else {
331 tgt += 1.0;
332 }
333 }
334 bin_w[b] = if tgt > 0.0 {
335 (1.0 - scale * dec / tgt).clamp(0.0, 1.0)
336 } else {
337 0.0
338 };
339 }
340 let mut vals: Vec<f64> = Vec::new();
343 let mut cnt: Vec<usize> = Vec::new();
344 for &w in &bin_w {
345 vals.push(w);
346 cnt.push(1);
347 while vals.len() > 1 && vals[vals.len() - 2] < vals[vals.len() - 1] {
348 let (v2, c2) = (vals.pop().unwrap(), cnt.pop().unwrap());
349 let (v1, c1) = (vals.pop().unwrap(), cnt.pop().unwrap());
350 vals.push((v1 * c1 as f64 + v2 * c2 as f64) / (c1 + c2) as f64);
351 cnt.push(c1 + c2);
352 }
353 }
354 let mut mono: Vec<f64> = Vec::with_capacity(nbins);
355 for (v, c) in vals.iter().zip(&cnt) {
356 for _ in 0..*c {
357 mono.push(*v);
358 }
359 }
360 let mut out: rustc_hash::FxHashMap<usize, f64> = rustc_hash::FxHashMap::default();
361 for b in 0..nbins {
362 let lo = bin_start[b];
363 let hi = if b + 1 < nbins { bin_start[b + 1] } else { n };
364 let w = mono.get(b).copied().unwrap_or(0.0);
365 for it in &items[lo..hi] {
366 out.insert(it.2, w);
367 }
368 }
369 out
370}
371
372struct FinalizedCandidate {
375 proteins: Vec<ProteinId>,
376 n_confident: usize,
377 peptides: Vec<(u32, u32, bool)>,
378 diagnostic: Option<Vec<(usize, usize, bool, f64, f64, f64, bool)>>,
380 ml_selected: bool,
381}
382
383fn soft_depth(proteins: &[ProteinId]) -> f64 {
384 proteins
385 .iter()
386 .filter(|p| !p.decoy)
387 .map(|p| (1.0 - p.q_value / 0.05).clamp(0.0, 1.0))
388 .sum()
389}
390
391fn select_deviation_cut<'a>(
395 cuts: impl IntoIterator<Item = &'a [ProteinId]>,
396 retain_zero: bool,
397) -> Option<usize> {
398 let mut best: Option<(usize, (usize, usize))> = None;
399 for (i, proteins) in cuts.into_iter().enumerate() {
400 let n = proteins
401 .iter()
402 .filter(|p| !p.decoy && p.q_value <= 0.01)
403 .count();
404 if n == 0 && !retain_zero {
405 continue;
406 }
407 let training = if n == 0 {
408 proteins
409 .iter()
410 .filter(|p| !p.decoy && p.q_value <= 0.25)
411 .count()
412 } else {
413 0
414 };
415 let rank = (n, training);
416 if best.as_ref().is_none_or(|(_, previous)| rank > *previous) {
417 best = Some((i, rank));
418 }
419 }
420 best.map(|(i, _)| i)
421}
422
423fn select_finalized_candidate(
424 candidates: Vec<FinalizedCandidate>,
425 count_only: bool,
426 protect_discoveries: bool,
427) -> (usize, FinalizedCandidate) {
428 let mut best: Option<(usize, FinalizedCandidate)> = None;
429 let mut best_rank = (false, f64::NEG_INFINITY);
430 for (i, candidate) in candidates.into_iter().enumerate() {
431 let score = if count_only {
432 candidate.n_confident as f64
433 } else {
434 soft_depth(&candidate.proteins)
435 };
436 let rank = (protect_discoveries && candidate.n_confident > 0, score);
440 if rank > best_rank {
442 best_rank = rank;
443 best = Some((i, candidate));
444 }
445 }
446 best.expect("at least one calibration candidate")
447}
448
449fn recover_zero_discoveries(
453 original: ScoreResult,
454 retry: impl FnOnce() -> ScoreResult,
455) -> ScoreResult {
456 let has_discoveries =
457 |r: &ScoreResult| r.proteins.iter().any(|p| !p.decoy && p.q_value <= 0.01);
458 if has_discoveries(&original) {
459 return original;
460 }
461 let recovered = retry();
462 if has_discoveries(&recovered) {
463 log::info!("mass-anchor: evidence-retention recovery produced reported 1% targets");
464 recovered
465 } else {
466 log::info!("mass-anchor: recovery has no reported 1% targets; preserving original result");
467 original
468 }
469}
470
471fn run_recovery_bootstraps(
474 has_rt: bool,
475 mut run_pass: impl FnMut(bool, bool) -> ScoreResult,
476) -> ScoreResult {
477 if !has_rt {
478 return ScoreResult {
479 proteins: Vec::new(),
480 peptides: Vec::new(),
481 };
482 }
483 let v3_started = std::time::Instant::now();
484 let initial_v3 = run_pass(false, false);
485 let original_v3 = recover_zero_discoveries(initial_v3, || {
486 log::info!("mass-anchor: original search has no reported 1% targets; attempting evidence-retention recovery");
487 run_pass(false, true)
488 });
489 log::info!(
490 "bounded-rt-recovery timing stage=complete_v3 elapsed_seconds={:.6} reported_q1_targets={}",
491 v3_started.elapsed().as_secs_f64(),
492 original_v3
493 .proteins
494 .iter()
495 .filter(|p| !p.decoy && p.q_value <= 0.01)
496 .count()
497 );
498 recover_zero_discoveries(original_v3, || {
499 log::info!("bounded-rt-recovery: complete V3 has no reported 1% targets; starting one broad pass retain_zero=false applied_broad_floor=0.0");
500 let broad_started = std::time::Instant::now();
501 let recovered = run_pass(true, false);
502 log::info!("bounded-rt-recovery timing stage=single_broad elapsed_seconds={:.6} reported_q1_targets={} retain_zero=false applied_broad_floor=0.0",
503 broad_started.elapsed().as_secs_f64(),
504 recovered.proteins.iter().filter(|p| !p.decoy && p.q_value <= 0.01).count());
505 recovered
506 })
507}
508
509fn mass_anchor_calibration(
519 reduced: &[usize],
520 pfms: &[Pfm],
521 matrix: &FeatureMatrix,
522 db: &PeptideDb,
523 abstract_rt: &[Option<f64>],
524 cfg: &ScoreConfig,
525 retain_zero_discovery_evidence: bool,
526 broad_rt_bootstrap: bool,
527 im_model: Option<&crate::im::ImModel>,
528) -> (Calibration, Vec<usize>, f64, f64) {
529 use std::collections::{HashMap, HashSet};
530 const MIN_ISOTOPES: f64 = 3.0;
539 let has_iso = matrix.features.iter().any(|f| f.n_isotopes.is_some());
540 let mut best: HashMap<usize, (f64, usize)> = HashMap::new();
541 for (pos, &i) in reduced.iter().enumerate() {
542 if pfms[i].isotope_offset != 0 {
543 continue;
544 }
545 if matrix.joint_evidence.as_ref().is_some_and(|groups| {
546 !groups[pfms[i].feature_idx as usize]
547 .first()
548 .is_some_and(|o| o.bootstrap_eligible)
549 }) {
550 continue;
551 }
552 if has_iso
553 && matrix.features[pfms[i].feature_idx as usize]
554 .n_isotopes
555 .unwrap_or(0.0)
556 < MIN_ISOTOPES
557 {
558 continue;
559 }
560 let cand_err = pfms[i].mass_error_ppm;
561 let e = best
562 .entry(pfms[i].peptide_idx as usize)
563 .or_insert((f64::INFINITY, pos));
564 let better = if cand_err.abs() < e.0.abs() {
565 true
566 } else if cand_err.abs() == e.0.abs() && e.0.is_finite() {
567 let cf = &matrix.features[pfms[i].feature_idx as usize];
574 let ef = &matrix.features[pfms[reduced[e.1]].feature_idx as usize];
575 feature_cmp(cf, ef) == Ordering::Less
576 } else {
577 false
578 };
579 if better {
580 *e = (cand_err, pos);
581 }
582 }
583 if best.len() < 100 {
584 return (Calibration::default(), Vec::new(), 0.0, 1.0);
585 }
586 let mut pep_pfms: HashMap<usize, Vec<(f64, usize)>> = HashMap::new();
596 for (pos, &i) in reduced.iter().enumerate() {
597 if pfms[i].isotope_offset != 0 {
598 continue;
599 }
600 pep_pfms
601 .entry(pfms[i].peptide_idx as usize)
602 .or_default()
603 .push((pfms[i].mass_error_ppm, pos));
604 }
605 for v in pep_pfms.values_mut() {
612 v.sort_by(|&(_, pa), &(_, pb)| {
613 let fa = &matrix.features[pfms[reduced[pa]].feature_idx as usize];
614 let fb = &matrix.features[pfms[reduced[pb]].feature_idx as usize];
615 feature_cmp(fa, fb)
616 });
617 }
618 let mut theo: rustc_hash::FxHashMap<(bool, &str), u32> = rustc_hash::FxHashMap::default();
621 for pep in &db.peptides {
622 for prot in &pep.proteins {
623 *theo.entry((pep.decoy, prot.as_ref())).or_insert(0) += 1;
624 }
625 }
626 let decoy_theo_sum: u64 = theo
627 .iter()
628 .filter(|((d, _), _)| *d)
629 .map(|(_, &c)| c as u64)
630 .sum();
631
632 let (mut shift, mut sigma) = (0.0f64, 3.5f64);
638 let mut anchor_pos: Vec<usize> = Vec::new();
639 let (mut last_nt, mut last_p) = (0usize, 0.0f64);
640 let mut mass_confident: HashSet<(bool, &str)> = HashSet::new();
641 #[allow(clippy::type_complexity)]
651 let mut best_round: Option<(usize, f64, f64, Vec<usize>, f64, HashSet<(bool, &str)>)> = None;
652 const MIN_CALIB_ANCHORS: usize = 50;
655 #[allow(clippy::type_complexity)]
656 let mut rescue: Option<(usize, HashSet<(bool, &str)>, Vec<usize>, Vec<f64>, f64)> = None;
657 let mut rescued_bootstrap = false;
658 for round in 0..5 {
659 let tol = if round == 0 {
660 cfg.mass_anchor_ppm
661 } else {
662 (3.0 * sigma).min(cfg.mass_anchor_ppm)
663 };
664 let mut prot_peps: HashMap<(bool, &str), Vec<usize>> = HashMap::new();
666 for (&pi, &(err, pos)) in &best {
667 if (err - shift).abs() > tol {
668 continue;
669 }
670 let pep = &db.peptides[pi];
671 for prot in &pep.proteins {
672 prot_peps
673 .entry((pep.decoy, prot.as_ref()))
674 .or_default()
675 .push(pos);
676 }
677 }
678 let decoy_matched: u64 = prot_peps
679 .iter()
680 .filter(|((d, _), _)| *d)
681 .map(|(_, v)| v.len() as u64)
682 .sum();
683 let p = if decoy_theo_sum == 0 {
684 0.5
685 } else {
686 (decoy_matched as f64 / decoy_theo_sum as f64).clamp(1e-4, 0.95)
687 };
688 let mut scored: Vec<(f64, bool, (bool, &str))> = prot_peps
690 .iter()
691 .map(|(&key, peps)| {
692 let n = theo.get(&key).copied().unwrap_or(peps.len() as u32);
693 let s = -crate::protein::binom_sf_ge_k(peps.len() as u32, n, p).log10();
694 (s, key.0, key)
695 })
696 .collect();
697 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap().then_with(|| a.2.cmp(&b.2)));
698 let (mut t, mut d) = (0usize, 0usize);
700 let mut confident: HashSet<(bool, &str)> = HashSet::new();
701 for (_, decoy, key) in &scored {
702 if *decoy {
703 d += 1;
704 } else {
705 t += 1;
706 }
707 let q = if t == 0 { 1.0 } else { d as f64 / t as f64 };
708 if q <= 0.05 && !*decoy {
709 confident.insert(*key);
710 }
711 if t > 50 && q > 0.5 {
712 break;
713 }
714 }
715 let mut best_keys: Vec<usize> = best.keys().copied().collect();
719 best_keys.sort_unstable();
720 let collect_anchors = |confident: &HashSet<(bool, &str)>| -> (Vec<usize>, Vec<f64>) {
721 let mut anchor_pos: Vec<usize> = Vec::new();
722 let mut cerr: Vec<f64> = Vec::new();
723 for &pi in &best_keys {
724 let (err, pos) = best[&pi];
725 if (err - shift).abs() > tol {
726 continue;
727 }
728 let pep = &db.peptides[pi];
729 if pep.decoy {
737 continue;
738 }
739 if pep
740 .proteins
741 .iter()
742 .any(|pr| confident.contains(&(false, pr.as_ref())))
743 {
744 anchor_pos.push(pos);
745 cerr.push(err);
746 }
747 }
748 (anchor_pos, cerr)
749 };
750 let (ap, cerr) = collect_anchors(&confident);
751 const RESCUE_ANCHOR_PEPTIDES: usize = 250;
774 const RESCUE_MAX_PROTEINS: usize = 150;
775 if cerr.len() < MIN_CALIB_ANCHORS {
776 let (mut t, mut d) = (0usize, 0usize);
777 let mut rescued: HashSet<(bool, &str)> = HashSet::new();
778 let mut est: HashSet<usize> = HashSet::new();
780 let mut q_last = 0.0f64;
781 for (_, decoy, key) in &scored {
782 if *decoy {
783 d += 1;
784 continue;
785 }
786 t += 1;
787 rescued.insert(*key);
788 if let Some(v) = prot_peps.get(key) {
789 est.extend(v.iter().copied());
790 }
791 q_last = d as f64 / t as f64;
792 if est.len() >= RESCUE_ANCHOR_PEPTIDES || rescued.len() >= RESCUE_MAX_PROTEINS {
793 break;
794 }
795 }
796 let (rap, rcerr) = collect_anchors(&rescued);
797 log::info!(
798 "mass-anchor round {}: strict walk left {} proteins / {} anchor peptides (< {}); top-score rescue candidate: {} proteins / {} anchor peptides (d/t at cut {:.2})",
799 round, confident.len(), cerr.len(), MIN_CALIB_ANCHORS, rescued.len(), rcerr.len(), q_last
800 );
801 if rescue
802 .as_ref()
803 .is_none_or(|(_, _, ra, _, _)| rap.len() > ra.len())
804 {
805 rescue = Some((round, rescued, rap, rcerr, p));
806 }
807 }
808 anchor_pos = ap;
809 if cerr.len() >= MIN_CALIB_ANCHORS {
811 shift = median(&mut cerr.clone());
812 sigma = clipped_sigma(cerr.iter().map(|&e| e - shift)).max(0.1);
813 }
814 last_nt = confident.len();
815 last_p = p;
816 mass_confident = confident.clone();
817 log::info!(
818 "mass-anchor round {}: tol={:.2} ppm p={:.3} -> {} confident target proteins, {} anchor peptides (shift={:.3} sigma={:.3})",
819 round, tol, p, confident.len(), anchor_pos.len(), shift, sigma
820 );
821 let is_better = best_round.as_ref().is_none_or(|(bn, ..)| last_nt > *bn);
822 if is_better {
823 best_round = Some((
824 last_nt,
825 shift,
826 sigma,
827 anchor_pos.clone(),
828 last_p,
829 mass_confident.clone(),
830 ));
831 }
832 if confident.is_empty() {
833 break;
834 }
835 }
836 if let Some((bn, bshift, bsigma, banchor, bp, bconf)) = best_round {
837 last_nt = bn;
838 shift = bshift;
839 sigma = bsigma;
840 anchor_pos = banchor;
841 last_p = bp;
842 mass_confident = bconf;
843 }
844 if anchor_pos.len() < MIN_CALIB_ANCHORS {
845 if let Some((rr, rconf, rap, rcerr, rp)) = rescue.take() {
846 if rap.len() >= MIN_CALIB_ANCHORS {
847 shift = median(&mut rcerr.clone());
848 sigma = clipped_sigma(rcerr.iter().map(|&e| e - shift)).max(0.1);
849 log::info!(
850 "mass-anchor bootstrap starved ({} anchor peptides < {}): using round {} top-score rescue, {} proteins / {} anchor peptides, mass shift={:.3} ppm sigma={:.3} ppm",
851 anchor_pos.len(), MIN_CALIB_ANCHORS, rr, rconf.len(), rap.len(), shift, sigma
852 );
853 last_nt = rconf.len();
854 mass_confident = rconf;
855 anchor_pos = rap;
856 last_p = rp;
857 rescued_bootstrap = true;
858 }
859 }
860 }
861 anchor_pos.sort_unstable();
862 anchor_pos.dedup();
863 log::info!(
864 "mass-anchor bootstrap: {} confident target proteins, {} anchor peptides, p={:.3}, mass shift={:.3} ppm sigma={:.3} ppm (best-of-rounds)",
865 last_nt, anchor_pos.len(), last_p, shift, sigma
866 );
867 let broad = if broad_rt_bootstrap {
870 let broad_gate = cfg.mass_anchor_ppm.min((3.0 * sigma).max(1.0));
871 let broad_evidence: Vec<_> = reduced
872 .iter()
873 .enumerate()
874 .filter_map(|(pos, &i)| {
875 let p = &pfms[i];
876 let f = &matrix.features[p.feature_idx as usize];
877 crate::broad_rt::observation(
878 pos,
879 p,
880 f,
881 &db.peptides[p.peptide_idx as usize],
882 abstract_rt[pos],
883 shift,
884 broad_gate,
885 has_iso,
886 )
887 })
888 .collect();
889 let result = crate::broad_rt::fit(&broad_evidence);
890 drop(broad_evidence);
891 result
892 } else {
893 None
894 };
895 let broad_floor = 0.0; let freeze_rt = broad.is_some();
897 let (cal, clean) = if let Some(b) = broad {
898 (b.calibration, b.clean)
899 } else {
900 if anchor_pos.is_empty() {
901 return (Calibration::default(), Vec::new(), shift, sigma);
902 }
903 let pair_positions: Vec<usize> = if broad_rt_bootstrap {
905 anchor_pos
906 .iter()
907 .copied()
908 .filter(|&pos| abstract_rt[pos].is_some())
909 .collect()
910 } else {
911 Vec::new()
912 };
913 let pair_source = if broad_rt_bootstrap {
914 &pair_positions
915 } else {
916 &anchor_pos
917 };
918 let pairs: Vec<(f64, f64)> = pair_source
919 .iter()
920 .filter_map(|&pos| {
921 abstract_rt[pos].map(|a| {
922 (
923 a,
924 matrix.features[pfms[reduced[pos]].feature_idx as usize].rt,
925 )
926 })
927 })
928 .collect();
929 if pairs.len() < 50 {
930 return (Calibration::default(), anchor_pos, shift, sigma);
931 }
932 let (mut cal, mut inl) =
933 Calibration::fit_ransac_seeded(&pairs, 2.5, 4000, cfg.effective_rt_seed());
934 for thresh in [1.0_f64, 0.5, 0.3] {
935 let sub: Vec<(f64, f64)> = inl.iter().map(|&k| pairs[k]).collect();
936 if sub.len() < 50 {
937 break;
938 }
939 let (c, si) =
940 Calibration::fit_ransac_seeded(&sub, thresh, 4000, cfg.effective_rt_seed());
941 if si.len() < 50 {
942 break;
943 }
944 inl = si.iter().map(|&j| inl[j]).collect();
945 cal = c;
946 }
947 let clean: Vec<usize> = inl
949 .iter()
950 .map(|&k| {
951 if broad_rt_bootstrap {
952 pair_positions[k]
953 } else {
954 anchor_pos[k.min(anchor_pos.len() - 1)]
956 }
957 })
958 .collect();
959 let mut resid: Vec<f64> = inl
960 .iter()
961 .map(|&k| (pairs[k].1 - cal.apply(pairs[k].0)).abs())
962 .collect();
963 let med = if resid.is_empty() {
964 f64::NAN
965 } else {
966 median(&mut resid)
967 };
968 log::info!(
969 "mass-anchor RT calibration: {} RANSAC inliers, median |residual| = {:.2} min",
970 clean.len(),
971 med
972 );
973
974 (cal, clean)
975 };
976
977 let rt_diff_at = |cal: &Calibration, pos: usize| -> Option<f64> {
985 abstract_rt[pos]
986 .map(|a| matrix.features[pfms[reduced[pos]].feature_idx as usize].rt - cal.apply(a))
987 };
988 let calibration_observation =
989 |cal: &Calibration, pos: usize, mass_shift: f64, mass_gate: f64| -> Option<f64> {
990 let p = &pfms[reduced[pos]];
991 if let Some(groups) = &matrix.joint_evidence {
992 let predicted = cal.apply(abstract_rt[pos]?);
993 crate::joint::calibration_rt(
994 &groups[p.feature_idx as usize],
995 db.peptides[p.peptide_idx as usize].mass,
996 predicted,
997 mass_shift,
998 mass_gate,
999 )
1000 } else {
1001 Some(matrix.features[p.feature_idx as usize].rt)
1002 }
1003 };
1004 let mass_gate = (3.0_f64 * sigma).min(cfg.mass_anchor_ppm);
1005
1006 let rt_sig_floor: std::cell::Cell<f64> = std::cell::Cell::new(broad_floor);
1012 let set_rt_floor = |floor: f64| rt_sig_floor.set(floor.max(broad_floor));
1013 let rt_sig_mult: std::cell::Cell<f64> = std::cell::Cell::new(1.0);
1015 let smooth_level: u8 = std::env::var("UNO_SMOOTH")
1033 .ok()
1034 .and_then(|v| v.parse().ok())
1035 .unwrap_or(2);
1036 let smooth = smooth_level == 1;
1037 let multi_cand = smooth_level >= 1;
1038 let est_rt_sig =
1039 |cal: &Calibration, confident: &HashSet<String>, m_shift: f64, m_gate: f64| -> f64 {
1040 let mut rr: Vec<f64> = Vec::new(); let mut all_rd: Vec<f64> = Vec::new(); let mut pep_keys: Vec<usize> = pep_pfms.keys().copied().collect();
1048 pep_keys.sort_unstable();
1049 for pi in pep_keys {
1050 let pfm_list = &pep_pfms[&pi];
1051 let pep = &db.peptides[pi];
1052 if pep.decoy
1053 || !pep
1054 .proteins
1055 .iter()
1056 .any(|pr| confident.contains(pr.as_ref()))
1057 {
1058 continue;
1059 }
1060 let mut best_rd: Option<f64> = None;
1061 for &(err, pos) in pfm_list {
1062 if matrix.joint_evidence.is_none() && (err - m_shift).abs() > m_gate {
1063 continue;
1064 }
1065 let rd = if matrix.joint_evidence.is_some() {
1066 calibration_observation(cal, pos, m_shift, m_gate)
1067 .zip(abstract_rt[pos])
1068 .map(|(rt, a)| rt - cal.apply(a))
1069 } else {
1070 rt_diff_at(cal, pos)
1071 };
1072 if let Some(rd) = rd {
1073 all_rd.push(rd);
1074 if best_rd.is_none_or(|b: f64| rd.abs() < b.abs()) {
1075 best_rd = Some(rd);
1076 }
1077 }
1078 }
1079 if let Some(rd) = best_rd {
1080 rr.push(rd);
1081 }
1082 }
1083 if let Some(v) = std::env::var("UNO_RT_SIG")
1084 .ok()
1085 .and_then(|s| s.parse::<f64>().ok())
1086 {
1087 return v;
1088 }
1089 if std::env::var("UNO_RT_SIG_GCORE").is_ok() {
1090 return gaussian_core_sigma(rr.into_iter(), 15.0).clamp(0.3, 5.0);
1091 }
1092 let _ = &all_rd;
1102 if std::env::var("UNO_RT_NOISYGAUS").is_ok() {
1103 let (_shift, s) = noisygaus_fit(&rr, 15.0, 300);
1104 return s.clamp(0.3, 5.0);
1105 }
1106 if rescued_bootstrap {
1112 return gaussian_core_sigma(rr.into_iter(), 15.0)
1113 .clamp(0.3, 3.5)
1114 .max(rt_sig_floor.get())
1115 * rt_sig_mult.get();
1116 }
1117 clipped_sigma(rr.into_iter())
1118 .clamp(0.3, 3.5)
1119 .max(rt_sig_floor.get())
1120 * rt_sig_mult.get()
1121 };
1122
1123 let recal_mass = |conf: &HashSet<String>,
1132 cal: &Calibration,
1133 cur_shift: f64,
1134 cur_sigma: f64,
1135 cur_gate: f64|
1136 -> (f64, f64, f64) {
1137 let mut errs: Vec<f64> = Vec::new();
1138 for (&pi, pfm_list) in &pep_pfms {
1139 let pep = &db.peptides[pi];
1140 if pep.decoy || !pep.proteins.iter().any(|pr| conf.contains(pr.as_ref())) {
1141 continue;
1142 }
1143 let mut best: Option<(f64, f64)> = None; for &(err, pos) in pfm_list {
1147 if (err - cur_shift).abs() > cur_gate {
1148 continue;
1149 }
1150 if let Some(rd) = rt_diff_at(cal, pos) {
1151 if best.is_none_or(|(b, _): (f64, f64)| rd.abs() < b) {
1152 best = Some((rd.abs(), err));
1153 }
1154 }
1155 }
1156 if let Some((_, err)) = best {
1157 errs.push(err);
1158 }
1159 }
1160 if errs.len() < 50 {
1161 return (cur_shift, cur_sigma, cur_gate);
1162 }
1163 let shift = median(&mut errs.clone());
1164 let sigma = clipped_sigma(errs.iter().map(|&e| e - shift)).max(0.05);
1165 let gate = (3.0 * sigma).min(cur_gate);
1167 (shift, sigma, gate)
1168 };
1169 let stage_mass = std::env::var("UNO_STAGE_MASS")
1174 .map(|v| v == "1")
1175 .unwrap_or(false);
1176
1177 let run_deviation = |cal: &Calibration,
1183 rt_sig: f64,
1184 m_shift: f64,
1185 m_sigma: f64,
1186 m_gate: f64|
1187 -> (usize, f64, Vec<ProteinId>, HashSet<String>) {
1188 let mut pep_dev: HashMap<usize, (bool, f64, usize)> = HashMap::new();
1189 let cs_gate: f64 = std::env::var("UNO_CS_GATE")
1197 .ok()
1198 .and_then(|s| s.parse().ok())
1199 .unwrap_or(0.0);
1200 let want_feats = std::env::var("UNO_MULTI_FEAT").is_ok();
1205 let per_pep: Vec<(usize, (bool, f64, usize), Vec<(f64, usize)>)> = pep_pfms
1209 .par_iter()
1210 .filter_map(|(&pi, pfm_list)| {
1211 let decoy = db.peptides[pi].decoy;
1212 let mut best: Option<(f64, usize)> = None;
1213 let mut feats: Vec<(f64, usize)> = Vec::new();
1214 let pred = pfm_list
1218 .first()
1219 .and_then(|&(_, pos)| abstract_rt[pos])
1220 .map(|a| cal.apply(a));
1221 for &(err, pos) in pfm_list {
1222 let mz = (err - m_shift) / m_sigma;
1223 if matrix.joint_evidence.is_none() && mz.abs() > m_gate / m_sigma {
1224 continue;
1225 }
1226 if cs_gate > 0.0
1227 && matrix.features[pfms[reduced[pos]].feature_idx as usize].combined_score
1228 < cs_gate
1229 {
1230 continue;
1231 }
1232 if let Some(pr) = pred {
1233 let rd = matrix.features[pfms[reduced[pos]].feature_idx as usize].rt - pr;
1234 let dev = if let Some(groups) = &matrix.joint_evidence {
1235 let Some(d) = crate::joint::best_deviation(
1236 &groups[pfms[reduced[pos]].feature_idx as usize],
1237 db.peptides[pi].mass,
1238 pr,
1239 m_shift,
1240 m_sigma,
1241 rt_sig,
1242 m_gate,
1243 ) else {
1244 continue;
1245 };
1246 d
1247 } else {
1248 mz * mz + (rd / rt_sig).powi(2)
1249 };
1250 if best.is_none_or(|(b, _)| dev < b) {
1251 best = Some((dev, pos));
1252 }
1253 if want_feats {
1254 feats.push((dev, pfms[reduced[pos]].feature_idx as usize));
1255 }
1256 }
1257 }
1258 best.map(|(dev, pos)| (pi, (decoy, dev, pos), feats))
1259 })
1260 .collect();
1261 let mut pep_all_feats: HashMap<usize, Vec<(f64, usize)>> =
1262 HashMap::with_capacity(per_pep.len());
1263 for (pi, dev, feats) in per_pep {
1264 pep_dev.insert(pi, dev);
1265 pep_all_feats.insert(pi, feats);
1266 }
1267 if std::env::var("UNO_CONTINUOUS").is_ok() {
1273 let scale = db.n_targets as f64 / db.n_decoys.max(1) as f64;
1274 let weights = dev_posteriors(&pep_dev, scale);
1275 let mut observed: rustc_hash::FxHashMap<(bool, &str), rustc_hash::FxHashSet<usize>> =
1276 rustc_hash::FxHashMap::default();
1277 let mut pep_feat: rustc_hash::FxHashMap<usize, Vec<usize>> =
1278 rustc_hash::FxHashMap::default();
1279 for (&pi, &(decoy, _dev, pos)) in &pep_dev {
1280 if weights.get(&pi).copied().unwrap_or(0.0) <= 1e-3 {
1281 continue;
1282 }
1283 pep_feat.insert(pi, vec![pfms[reduced[pos]].feature_idx as usize]);
1284 for prot in &db.peptides[pi].proteins {
1285 observed
1286 .entry((decoy, prot.as_ref()))
1287 .or_default()
1288 .insert(pi);
1289 }
1290 }
1291 let scored = crate::protein::score_proteins_weighted(
1292 observed,
1293 &pep_feat,
1294 &weights,
1295 &theo,
1296 decoy_theo_sum,
1297 );
1298 let nt = scored
1299 .iter()
1300 .filter(|p| !p.decoy && p.q_value <= 0.01)
1301 .count();
1302 let conf: HashSet<String> = scored
1303 .iter()
1304 .filter(|p| !p.decoy && p.q_value <= 0.01)
1305 .map(|p| p.accession.clone())
1306 .collect();
1307 log::info!(
1308 "continuous-posterior scorer: {} target proteins @1% FDR ({} peptides weighted, no cut grid)",
1309 nt,
1310 pep_feat.len()
1311 );
1312 return (nt, 0.0, scored, conf);
1313 }
1314 if let Ok(path) = std::env::var("UNO_DUMP_PEPSET") {
1315 use std::io::Write;
1316 if let Ok(f) = std::fs::File::create(&path) {
1317 let mut w = std::io::BufWriter::new(f);
1318 let _ = writeln!(
1322 w,
1323 "peptide\tdecoy\tdev\tfeature\tmass_ppm\trt_diff\tproteins"
1324 );
1325 for (&pi, &(decoy, _, _)) in &pep_dev {
1326 let pep = &db.peptides[pi];
1327 let prots: Vec<&str> = pep.proteins.iter().map(|p| p.as_ref()).collect();
1328 let pstr = prots.join(";");
1329 if let Some(list) = pep_pfms.get(&pi) {
1331 for &(err, pos) in list {
1332 let mz = (err - m_shift) / m_sigma;
1333 if mz.abs() > m_gate / m_sigma {
1334 continue;
1335 }
1336 if let Some(rd) = rt_diff_at(cal, pos) {
1337 let dev = mz * mz + (rd / rt_sig).powi(2);
1338 let feat = pfms[reduced[pos]].feature_idx as usize;
1339 let _ = writeln!(
1340 w,
1341 "{}\t{}\t{:.3}\t{}\t{:.4}\t{:.4}\t{}",
1342 pep.sequence.as_ref(),
1343 decoy as u8,
1344 dev,
1345 feat,
1346 err,
1347 rd,
1348 pstr
1349 );
1350 }
1351 }
1352 }
1353 }
1354 }
1355 }
1356 let mut best_proteins: Vec<ProteinId> = Vec::new();
1357 let mut best_n = 0usize;
1358 let mut best_cut = 0.0f64;
1359 let ensemble = std::env::var("UNO_ENSEMBLE").is_ok();
1364 let multi_feat = std::env::var("UNO_MULTI_FEAT").is_ok();
1365 let no_feat_compete = std::env::var("UNO_NO_FEATURE_COMPETE").is_ok();
1366 let log_pepcount = std::env::var("UNO_LOG_PEPCOUNT").is_ok();
1367 let mut acc: HashMap<(bool, String), (f64, usize, usize)> = HashMap::new();
1368 let cut_step: f64 = std::env::var("UNO_CUT_STEP")
1379 .ok()
1380 .and_then(|s| s.parse().ok())
1381 .unwrap_or(0.5);
1382 let cut_max: f64 = std::env::var("UNO_CUT_MAX")
1383 .ok()
1384 .and_then(|s| s.parse().ok())
1385 .unwrap_or(8.0);
1386 let cut_grid: Vec<f64> = {
1387 let mut g: Vec<f64> = Vec::new();
1388 let mut c = 1.0;
1389 while c <= 12.0 {
1390 g.push(c);
1391 c += cut_step;
1392 }
1393 g.extend_from_slice(&[13.0, 14.0, 16.0, 18.0, 20.0, 25.0]);
1394 g.retain(|&x| x <= cut_max);
1395 g
1396 };
1397 let _sweep_t0 = std::time::Instant::now();
1403 let mut per_cut: Vec<(f64, usize, usize, usize, Vec<ProteinId>)> = cut_grid
1404 .par_iter()
1405 .map(|&cut| {
1406 let mut observed: rustc_hash::FxHashMap<
1410 (bool, &str),
1411 rustc_hash::FxHashSet<usize>,
1412 > = rustc_hash::FxHashMap::default();
1413 let mut decoy_seen: rustc_hash::FxHashSet<usize> = rustc_hash::FxHashSet::default();
1414 let mut pep_feat: rustc_hash::FxHashMap<usize, Vec<usize>> =
1418 rustc_hash::FxHashMap::default();
1419 for (&pi, &(decoy, dev, pos)) in &pep_dev {
1420 if dev <= cut {
1421 if decoy {
1422 decoy_seen.insert(pi);
1423 }
1424 let feats: Vec<usize> = if multi_feat {
1428 let mut f: Vec<usize> = pep_all_feats
1429 .get(&pi)
1430 .map(|v| {
1431 v.iter()
1432 .filter(|(d, _)| *d <= cut)
1433 .map(|(_, f)| *f)
1434 .collect()
1435 })
1436 .unwrap_or_default();
1437 f.sort_unstable();
1438 f.dedup();
1439 if f.is_empty() {
1440 f.push(pfms[reduced[pos]].feature_idx as usize);
1441 }
1442 f
1443 } else {
1444 vec![pfms[reduced[pos]].feature_idx as usize]
1445 };
1446 pep_feat.insert(pi, feats);
1447 for prot in &db.peptides[pi].proteins {
1448 observed
1449 .entry((decoy, prot.as_ref()))
1450 .or_default()
1451 .insert(pi);
1452 }
1453 }
1454 }
1455 let scored = if no_feat_compete {
1458 crate::protein::score_proteins_greedy(
1459 observed,
1460 &theo,
1461 decoy_theo_sum,
1462 decoy_seen.len(),
1463 0.01,
1464 )
1465 } else {
1466 crate::protein::score_proteins_feature_compete(
1467 observed,
1468 &pep_feat,
1469 &theo,
1470 decoy_theo_sum,
1471 0.01,
1472 )
1473 };
1474 let nt = scored
1475 .iter()
1476 .filter(|p| !p.decoy && p.q_value <= 0.01)
1477 .count();
1478 let tgt = if log_pepcount {
1479 pep_dev
1480 .iter()
1481 .filter(|(_, &(d, dev, _))| !d && dev <= cut)
1482 .count()
1483 } else {
1484 0
1485 };
1486 (cut, nt, tgt, decoy_seen.len(), scored)
1487 })
1488 .collect();
1489 log::info!(
1490 "run_deviation cut-sweep: {} cuts (max {:.0}) in {:.2}s",
1491 cut_grid.len(),
1492 cut_max,
1493 _sweep_t0.elapsed().as_secs_f64()
1494 );
1495 if ensemble {
1499 for (_, _, _, _, scored) in &per_cut {
1500 for p in scored {
1501 let e = acc
1502 .entry((p.decoy, p.accession.clone()))
1503 .or_insert((0.0, 0, 0));
1504 e.0 += p.binomial_score;
1505 e.1 = e.1.max(p.observed_peptides);
1506 e.2 = p.theoretical_peptides;
1507 }
1508 }
1509 }
1510 let best_idx = select_deviation_cut(
1511 per_cut.iter().map(|cut| cut.4.as_slice()),
1512 retain_zero_discovery_evidence,
1513 );
1514 if let Some(i) = best_idx {
1515 let (cut, nt, tgt, dec) = (per_cut[i].0, per_cut[i].1, per_cut[i].2, per_cut[i].3);
1516 best_n = nt;
1517 best_cut = cut;
1518 if log_pepcount {
1519 log::info!("run_deviation cut={:.1}: {} proteins; distinct peptides target={} decoy={} (decoy/target={:.2})",
1520 cut, nt, tgt, dec, dec as f64 / tgt.max(1) as f64);
1521 }
1522 if nt == 0 {
1523 let training = per_cut[i]
1524 .4
1525 .iter()
1526 .filter(|p| !p.decoy && p.q_value <= 0.25)
1527 .count();
1528 log::info!("run_deviation: no 1% discoveries; retaining cut {:.1} with {} training targets at q<=25%", cut, training);
1529 }
1530 best_proteins = std::mem::take(&mut per_cut[i].4);
1531 }
1532 if ensemble {
1533 let nc = cut_grid.len() as f64;
1535 let mut ep: Vec<ProteinId> = acc
1536 .iter()
1537 .map(|((decoy, a), &(s, obs, theo))| ProteinId {
1538 accession: a.clone(),
1539 decoy: *decoy,
1540 observed_peptides: obs,
1541 theoretical_peptides: theo,
1542 coverage: if theo > 0 {
1543 obs as f64 / theo as f64
1544 } else {
1545 0.0
1546 },
1547 binomial_score: s / nc,
1548 mean_rt_diff: 0.0,
1549 mean_mass_z: 0.0,
1550 mean_pep_score: 0.0,
1551 q_value: 1.0,
1552 })
1553 .collect();
1554 ep.sort_by(|a, b| {
1555 b.binomial_score
1556 .partial_cmp(&a.binomial_score)
1557 .unwrap()
1558 .then_with(|| a.accession.cmp(&b.accession))
1559 .then(a.decoy.cmp(&b.decoy))
1560 });
1561 let score_by: HashMap<(bool, String), f64> = ep
1562 .iter()
1563 .map(|p| ((p.decoy, p.accession.clone()), p.binomial_score))
1564 .collect();
1565 ep.retain(|p| match score_by.get(&(!p.decoy, p.accession.clone())) {
1566 Some(&cs) => p.binomial_score > cs || (p.binomial_score == cs && !p.decoy),
1567 None => true,
1568 });
1569 let (mut t, mut d) = (0usize, 0usize);
1570 for p in ep.iter_mut() {
1571 if p.decoy {
1572 d += 1;
1573 } else {
1574 t += 1;
1575 }
1576 p.q_value = if t == 0 {
1577 1.0
1578 } else {
1579 (d as f64 + 1.0) / t as f64
1580 };
1581 }
1582 let mut run = f64::INFINITY;
1583 for i in (0..ep.len()).rev() {
1584 run = run.min(ep[i].q_value);
1585 ep[i].q_value = run.min(1.0);
1586 }
1587 let ne = ep.iter().filter(|p| !p.decoy && p.q_value <= 0.01).count();
1588 log::info!(
1589 "run_deviation ENSEMBLE over {} cuts: {} proteins (best-cut was {})",
1590 cut_grid.len(),
1591 ne,
1592 best_n
1593 );
1594 best_n = ne;
1595 best_proteins = ep;
1596 }
1597 let conf: HashSet<String> = best_proteins
1598 .iter()
1599 .filter(|pp| !pp.decoy && pp.q_value <= 0.01)
1600 .map(|pp| pp.accession.clone())
1601 .collect();
1602 (best_n, best_cut, best_proteins, conf)
1603 };
1604
1605 if rescued_bootstrap && !clean.is_empty() {
1613 let inlier_accs: HashSet<&str> = clean
1614 .iter()
1615 .flat_map(|&pos| {
1616 db.peptides[pfms[reduced[pos]].peptide_idx as usize]
1617 .proteins
1618 .iter()
1619 .map(|p| p.as_ref())
1620 })
1621 .collect();
1622 let before = mass_confident.len();
1623 mass_confident.retain(|(_, acc)| inlier_accs.contains(acc));
1624 log::info!(
1625 "mass-anchor rescue: {} of {} rescued proteins own an RT-inlier anchor; using those as the stage-1 confident set",
1626 mass_confident.len(), before
1627 );
1628 }
1629 let mass_conf_accs: HashSet<String> = mass_confident
1631 .iter()
1632 .filter(|(d, _)| !d)
1633 .map(|(_, a)| a.to_string())
1634 .collect();
1635
1636 let build_cal_pairs =
1637 |conf: &HashSet<String>, cal: &Calibration, m_shift: f64, m_gate: f64| -> Vec<(f64, f64)> {
1638 let mut cp: Vec<(f64, f64)> = Vec::new();
1639 for (&pi, pfm_list) in &pep_pfms {
1640 let pep = &db.peptides[pi];
1641 if pep.decoy || !pep.proteins.iter().any(|pr| conf.contains(pr.as_ref())) {
1642 continue;
1643 }
1644 let mut best: Option<(f64, usize, f64)> = None;
1645 for &(err, pos) in pfm_list {
1646 if matrix.joint_evidence.is_none() && (err - m_shift).abs() > m_gate {
1647 continue;
1648 }
1649 if let (Some(observed), Some(a)) = (
1650 calibration_observation(cal, pos, m_shift, m_gate),
1651 abstract_rt[pos],
1652 ) {
1653 let rd = observed - cal.apply(a);
1654 if best.is_none_or(|(b, _, _)| rd.abs() < b.abs()) {
1655 best = Some((rd, pos, observed));
1656 }
1657 }
1658 }
1659 if let Some((_, pos, observed)) = best {
1660 if let Some(a) = abstract_rt[pos] {
1661 cp.push((a, observed));
1662 }
1663 }
1664 }
1665 cp.sort_by(|a, b| {
1666 a.0.partial_cmp(&b.0)
1667 .unwrap()
1668 .then(a.1.partial_cmp(&b.1).unwrap())
1669 });
1670 cp
1671 };
1672 let n_stages: usize = std::env::var("UNO_RT_STAGES")
1673 .ok()
1674 .and_then(|s| s.parse().ok())
1675 .unwrap_or(4);
1676
1677 let run_stages = |floor: f64, mult: f64, seed: &HashSet<String>| {
1686 set_rt_floor(floor);
1687 rt_sig_mult.set(mult);
1688 let rt_sig1 = est_rt_sig(&cal, &mass_conf_accs, shift, mass_gate);
1689 let (n1, cut1, prot1, conf1) = run_deviation(&cal, rt_sig1, shift, sigma, mass_gate);
1690 let mut best_soft = soft_depth(&prot1);
1691 let (mut best_n, mut best_cut, mut best_proteins, mut best_cal) =
1692 (n1, cut1, prot1.clone(), cal.clone());
1693 let (mut best_shift, mut best_sigma, mut best_gate) = (shift, sigma, mass_gate);
1694 let mut cur_conf: HashSet<String> = conf1.union(seed).cloned().collect();
1695 let mut cur_cal = cal.clone();
1696 let (mut cur_shift, mut cur_sigma, mut cur_gate) = (shift, sigma, mass_gate);
1697 for stage in 2..=n_stages.max(2) {
1698 let (s_shift, s_sigma, s_gate) = if stage_mass {
1699 recal_mass(&cur_conf, &cur_cal, cur_shift, cur_sigma, cur_gate)
1700 } else {
1701 (shift, sigma, mass_gate)
1702 };
1703 let cal_pairs = build_cal_pairs(&cur_conf, &cur_cal, s_shift, s_gate);
1704 if cal_pairs.len() < 100 {
1705 break;
1706 }
1707 let cal_s = crate::broad_rt::stage_map(freeze_rt.then_some(&cal), || {
1708 Calibration::fit_robust(&cal_pairs, 60, 200)
1709 });
1710 let rt_sig_s = est_rt_sig(&cal_s, &cur_conf, s_shift, s_gate);
1711 let (ns, cuts, prots, confs) =
1712 run_deviation(&cal_s, rt_sig_s, s_shift, s_sigma, s_gate);
1713 log::info!("mass-anchor stage {}: {} confident (rt_sig {:.2}, mass {:.2}+/-{:.2}ppm gate {:.2}, {} conf-peptides)",
1714 stage, ns, rt_sig_s, s_shift, s_sigma, s_gate, cal_pairs.len());
1715 let soft_s = soft_depth(&prots);
1716 let better = if smooth {
1717 soft_s > best_soft
1718 } else {
1719 ns > best_n
1720 };
1721 if better {
1722 best_n = ns;
1723 best_cut = cuts;
1724 best_proteins = prots;
1725 best_cal = cal_s.clone();
1726 best_shift = s_shift;
1727 best_sigma = s_sigma;
1728 best_gate = s_gate;
1729 best_soft = soft_s;
1730 }
1731 cur_shift = s_shift;
1732 cur_sigma = s_sigma;
1733 cur_gate = s_gate;
1734 if smooth {
1735 if !better {
1736 break;
1737 }
1738 } else if confs.len() <= cur_conf.len() {
1739 break;
1740 }
1741 cur_cal = cal_s;
1742 cur_conf = confs;
1743 }
1744 (
1745 best_n,
1746 best_cut,
1747 best_proteins,
1748 best_cal,
1749 best_shift,
1750 best_sigma,
1751 best_gate,
1752 conf1,
1753 rt_sig1,
1754 best_soft,
1755 )
1756 };
1757 type StageRun = (
1763 usize,
1764 f64,
1765 Vec<ProteinId>,
1766 Calibration,
1767 f64,
1768 f64,
1769 f64,
1770 HashSet<String>,
1771 f64,
1772 f64,
1773 );
1774 let empty: HashSet<String> = HashSet::new();
1775 let mut cands: Vec<(f64, f64)> = vec![(0.0, 1.0)];
1776 if multi_cand {
1777 cands.push((0.0, 1.5));
1778 }
1779 if multi_cand && mass_conf_accs.len() < 40 {
1795 cands.push((0.0, 1.0 / 1.5));
1796 }
1797 let mut runs: Vec<(StageRun, f64, f64)> = Vec::new();
1798 for &(fl, mu) in &cands {
1799 let r = run_stages(fl, mu, &empty);
1800 if multi_cand {
1801 log::info!(
1802 "mass-anchor candidate (sigma x{:.1}): {} confident, soft depth {:.1}",
1803 mu,
1804 r.0,
1805 r.9
1806 );
1807 }
1808 runs.push((r, fl, mu));
1809 }
1810 let base_n = runs.iter().map(|(r, _, _)| r.0).max().unwrap_or(0);
1818 if base_n < 20 && !mass_conf_accs.is_empty() {
1819 for (fl, mu) in [(0.0_f64, 1.0_f64), (1.0, 1.0)] {
1820 let r = run_stages(fl, mu, &mass_conf_accs);
1821 log::info!(
1822 "mass-anchor stage 1 collapsed ({} confident); retry seeded from {} bootstrap proteins, RT sigma floor {:.1}: {} confident (soft {:.1})",
1823 base_n, mass_conf_accs.len(), fl, r.0, r.9
1824 );
1825 runs.push((r, fl, mu));
1826 }
1827 }
1828 let run_ml = |best_proteins: &Vec<ProteinId>,
1830 best_cal: &Calibration,
1831 best_shift: f64,
1832 best_sigma: f64,
1833 best_gate: f64,
1834 best_n: usize|
1835 -> Option<FinalizedCandidate> {
1836 let conf: HashSet<String> = best_proteins
1840 .iter()
1841 .filter(|p| !p.decoy && p.q_value <= 0.25)
1842 .map(|p| p.accession.clone())
1843 .collect();
1844 if cfg.ml_rescore && conf.len() >= (if smooth { 10 } else { 20 }) {
1845 let rt_sig_ml = est_rt_sig(best_cal, &conf, best_shift, best_gate).max(0.5);
1850 let cs_gate_ml: f64 = std::env::var("UNO_CS_GATE_ML")
1857 .ok()
1858 .and_then(|s| s.parse().ok())
1859 .unwrap_or(0.0);
1860 let mut pep_best: HashMap<usize, (bool, usize, f64)> = HashMap::new();
1861 for (&pi, pfm_list) in &pep_pfms {
1862 let decoy = db.peptides[pi].decoy;
1863 let mut best: Option<(f64, usize)> = None;
1864 let predicted_rt = pfm_list
1865 .first()
1866 .and_then(|&(_, pos)| abstract_rt[pos])
1867 .map(|a| best_cal.apply(a));
1868 for &(err, pos) in pfm_list {
1869 let mz = (err - best_shift) / best_sigma;
1870 if matrix.joint_evidence.is_none() && mz.abs() > best_gate / best_sigma {
1871 continue;
1872 }
1873 if cs_gate_ml > 0.0
1874 && matrix.features[pfms[reduced[pos]].feature_idx as usize].combined_score
1875 < cs_gate_ml
1876 {
1877 continue;
1878 }
1879 if let Some(rd) = rt_diff_at(best_cal, pos) {
1880 let dev = if let Some(groups) = &matrix.joint_evidence {
1881 let Some(d) = crate::joint::best_deviation(
1882 &groups[pfms[reduced[pos]].feature_idx as usize],
1883 db.peptides[pi].mass,
1884 predicted_rt.unwrap(),
1885 best_shift,
1886 best_sigma,
1887 rt_sig_ml,
1888 best_gate,
1889 ) else {
1890 continue;
1891 };
1892 d
1893 } else {
1894 mz * mz + (rd / rt_sig_ml).powi(2)
1895 };
1896 if best.is_none_or(|(b, _)| dev < b) {
1897 best = Some((dev, pos));
1898 }
1899 }
1900 }
1901 if let Some((dev, pos)) = best {
1902 pep_best.insert(pi, (decoy, pos, dev));
1903 }
1904 }
1905 let mut peptides: Vec<(u32, u32, bool)> = pep_best
1909 .iter()
1910 .map(|(&pi, &(decoy, pos, _))| (pi as u32, pfms[reduced[pos]].feature_idx, decoy))
1911 .collect();
1912 peptides.sort_unstable();
1913 let mut items: Vec<(usize, bool, usize, f64)> = pep_best
1920 .iter()
1921 .filter(|(_, &(_, _, dev))| dev <= 100.0)
1922 .map(|(&pi, &(d, pos, dev))| (pi, d, pos, dev))
1923 .collect();
1924 items.sort_unstable_by_key(|&(pi, _, _, _)| pi);
1930 let im_residuals = if matrix.joint_evidence.is_none()
1933 && items.iter().any(|&(_, _, pos, _)| {
1934 matrix.features[pfms[reduced[pos]].feature_idx as usize]
1935 .im
1936 .is_some_and(|v| v.is_finite() && v > 0.0)
1937 }) {
1938 im_model.map(|model| {
1939 let queries: Vec<(&str, u8, Option<f64>)> = items
1940 .iter()
1941 .map(|&(pi, _, pos, _)| {
1942 let f = &matrix.features[pfms[reduced[pos]].feature_idx as usize];
1943 (db.peptides[pi].sequence.as_ref(), f.charge, f.im)
1944 })
1945 .collect();
1946 crate::im::soft_residuals(model, &queries)
1947 })
1948 } else {
1949 None
1950 };
1951 let im_by_pos: HashMap<usize, f64> = im_residuals
1952 .as_ref()
1953 .map(|residuals| {
1954 items
1955 .iter()
1956 .zip(residuals)
1957 .filter_map(|(&(_, _, pos, _), &r)| r.map(|r| (pos, r)))
1958 .collect()
1959 })
1960 .unwrap_or_default();
1961 let group_stats: HashMap<usize, (f64, f64, f64, f64, f32, f32, usize)> = {
1967 let med = |v: &mut Vec<f64>| -> f64 {
1968 if v.is_empty() {
1969 return 0.0;
1970 }
1971 v.sort_by(|a, b| a.partial_cmp(b).unwrap());
1972 v[v.len() / 2]
1973 };
1974 let mut m: HashMap<usize, (f64, f64, f64, f64, f32, f32, usize)> = HashMap::new();
1975 for &(pi, _, _, _) in &items {
1976 if m.contains_key(&pi) {
1977 continue;
1978 }
1979 let (mut rtds, mut mzs) = (Vec::new(), Vec::new());
1980 let mut chs: HashSet<u8> = HashSet::new();
1981 let mut ims: HashSet<i64> = HashSet::new();
1982 if let Some(list) = pep_pfms.get(&pi) {
1983 for &(err, pos) in list {
1984 let f = &matrix.features[pfms[reduced[pos]].feature_idx as usize];
1985 if let Some(groups) = &matrix.joint_evidence {
1986 let Some(pr) = abstract_rt[pos].map(|a| best_cal.apply(a)) else {
1987 continue;
1988 };
1989 let Some(e) = crate::joint::best_match(
1990 &groups[pfms[reduced[pos]].feature_idx as usize],
1991 db.peptides[pi].mass,
1992 pr,
1993 best_shift,
1994 best_sigma,
1995 rt_sig_ml,
1996 best_gate,
1997 ) else {
1998 continue;
1999 };
2000 if e.deviation > 9.0 {
2003 continue;
2004 }
2005 mzs.push(((e.mass_error - best_shift) / best_sigma).abs());
2006 rtds.push(e.rt_error.abs());
2007 } else {
2008 mzs.push(((err - shift) / sigma).abs());
2009 if let Some(rd) = rt_diff_at(best_cal, pos) {
2010 rtds.push(rd.abs());
2011 }
2012 }
2013 chs.insert(f.charge);
2014 ims.insert((f.im.unwrap_or(0.0) * 100.0) as i64);
2015 }
2016 }
2017 let (rtd_sum, mz_sum): (f64, f64) = (rtds.iter().sum(), mzs.iter().sum());
2018 let count = mzs.len();
2019 m.insert(
2020 pi,
2021 (
2022 med(&mut rtds),
2023 rtd_sum,
2024 med(&mut mzs),
2025 mz_sum,
2026 chs.len() as f32,
2027 ims.len() as f32,
2028 count,
2029 ),
2030 );
2031 }
2032 m
2033 };
2034 const AAS: &[u8] = b"GASPVTCLINDQKEMHFRYW";
2038 let featurize_ml = |pos: usize, pi: usize| -> Vec<f32> {
2039 let mut v = featurize(
2040 &pfms[reduced[pos]],
2041 matrix,
2042 db,
2043 rt_diff_at(best_cal, pos),
2044 im_by_pos.get(&pos).copied(),
2045 );
2046 if im_residuals.is_some() {
2047 v.push(if im_by_pos.contains_key(&pos) {
2048 1.0
2049 } else {
2050 0.0
2051 });
2052 }
2053 if !cfg.matrix_recurrence && matrix.n_samples() > 1 {
2054 v[5] = 0.0;
2055 v[10] = 0.0;
2056 }
2057 let seq = db.peptides[pfms[reduced[pos]].peptide_idx as usize]
2058 .sequence
2059 .as_bytes();
2060 let mut comp = [0f32; 20];
2061 let len = seq.len().max(1) as f32;
2062 for &b in seq {
2063 if let Some(i) = AAS.iter().position(|&a| a == b) {
2064 comp[i] += 1.0;
2065 }
2066 }
2067 for c in comp.iter() {
2068 v.push(*c / len);
2069 }
2070 let (mut dp, mut kp, mut rp) = (0f32, 0f32, 0f32);
2071 for w in seq.windows(2) {
2072 match w {
2073 b"DP" => dp += 1.0,
2074 b"KP" => kp += 1.0,
2075 b"RP" => rp += 1.0,
2076 _ => {}
2077 }
2078 }
2079 v.push(dp);
2080 v.push(kp);
2081 v.push(rp);
2082 let (rtd_med, rtd_sum, mz_med, mz_sum, n_ch, n_im, coherent_count) = group_stats
2083 .get(&pi)
2084 .copied()
2085 .unwrap_or((0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0));
2086 let id_count = if matrix.joint_evidence.is_some() {
2087 coherent_count
2088 } else {
2089 pep_pfms.get(&pi).map_or(1, |l| l.len())
2090 };
2091 let rtd_abs = rt_diff_at(best_cal, pos).map(|x| x.abs()).unwrap_or(0.0);
2092 let mz_abs = ((pfms[reduced[pos]].mass_error_ppm - shift) / sigma).abs();
2093 v.push((1.0 + id_count as f64).ln() as f32);
2094 v.push((rtd_abs - rtd_med) as f32);
2095 v.push((rtd_abs / (rtd_sum + 0.01)) as f32);
2096 v.push((mz_abs - mz_med) as f32);
2097 v.push((mz_abs / (mz_sum + 0.01)) as f32);
2098 v.push((1.0 + n_ch as f64).ln() as f32);
2099 v.push((1.0 + n_im as f64).ln() as f32);
2100 let f = &matrix.features[pfms[reduced[pos]].feature_idx as usize];
2104 v.push(f.isoerror.unwrap_or(0.0) as f32);
2105 v.push(f.isoerror.map(|x| x.abs()).unwrap_or(0.0) as f32);
2106 v.push(f.isoerror2.unwrap_or(0.0) as f32);
2107 v.push(f.rt_width.unwrap_or(0.0) as f32);
2108 if let Some(groups) = &matrix.joint_evidence {
2109 let group = &groups[pfms[reduced[pos]].feature_idx as usize];
2110 let pr = best_cal.apply(abstract_rt[pos].expect("joint candidate requires RT"));
2111 let e = crate::joint::evidence(
2112 group,
2113 db.peptides[pi].mass,
2114 pr,
2115 best_shift,
2116 best_sigma,
2117 rt_sig_ml,
2118 best_gate,
2119 )
2120 .expect("selected joint candidate must have evidence");
2121 let f = &group[e.best].feature;
2122 v[0] = e.mass_error.abs() as f32;
2125 v[1] = e.mass_error as f32;
2126 v[3] = e.rt_error.abs() as f32;
2127 v[4] = f.combined_score as f32;
2128 v[5] = if cfg.matrix_joint_agreement && cfg.matrix_recurrence {
2129 e.values[0]
2130 } else {
2131 0.0
2132 };
2133 v[7] = (1.0 + f.intensities[0]).log10() as f32;
2134 v[10] = if cfg.matrix_joint_agreement && cfg.matrix_recurrence {
2135 e.values[1]
2136 } else {
2137 0.0
2138 };
2139 v[12] = f.n_isotopes.unwrap_or(0.0).ln_1p() as f32;
2140 v[13] = f.n_scans.unwrap_or(0.0).ln_1p() as f32;
2141 v[14] = f.cosine_score.unwrap_or(0.0) as f32;
2142 v[15] = f.isotope_score.unwrap_or(0.0) as f32;
2143 v[16] = f.ppm_error.unwrap_or(0.0) as f32;
2144 v[17] = f.neutron_offset.unwrap_or(0.0) as f32;
2145 let base = 18 + matrix.matrix_evidence.as_ref().map_or(0, |_| 9);
2146 v[base + 24] = (e.rt_error.abs() - rtd_med) as f32;
2147 v[base + 25] = (e.rt_error.abs() / (rtd_sum + 0.01)) as f32;
2148 let mass_z = ((e.mass_error - best_shift) / best_sigma).abs();
2149 v[base + 26] = (mass_z - mz_med) as f32;
2150 v[base + 27] = (mass_z / (mz_sum + 0.01)) as f32;
2151 let n = v.len();
2153 v[n - 4] = f.isoerror.unwrap_or(0.0) as f32;
2154 v[n - 3] = f.isoerror.unwrap_or(0.0).abs() as f32;
2155 v[n - 2] = f.isoerror2.unwrap_or(0.0) as f32;
2156 v[n - 1] = f.rt_width.unwrap_or(0.0) as f32;
2157 if cfg.matrix_joint_agreement {
2158 v.extend_from_slice(&e.values);
2159 } else {
2160 v.extend_from_slice(&[0.0; 14]);
2161 }
2162 if cfg.matrix_joint_cell_quality {
2163 let cells = matrix
2164 .joint_cell_evidence
2165 .as_ref()
2166 .expect("joint cell quality requires long-bundle extraction summaries");
2167 v.extend_from_slice(&crate::cell_evidence::select(
2170 cells[pfms[reduced[pos]].feature_idx as usize],
2171 cfg.matrix_joint_cell_counts,
2172 cfg.matrix_joint_cell_q,
2173 ));
2174 }
2175 }
2176 v
2177 };
2178 let feats: Vec<Vec<f32>> = items
2179 .iter()
2180 .map(|&(pi, _, pos, _)| featurize_ml(pos, pi))
2181 .collect();
2182 let folds: Vec<usize> = items
2185 .iter()
2186 .map(|&(pi, _, _, _)| fold_of(pi, cfg.n_folds, cfg.effective_cv_seed()))
2187 .collect();
2188 let train_label: Vec<Option<f64>> = items
2196 .iter()
2197 .map(|&(pi, decoy, _, _)| {
2198 if decoy {
2199 None
2200 } else if db.peptides[pi]
2201 .proteins
2202 .iter()
2203 .any(|pr| conf.contains(pr.as_ref()))
2204 {
2205 Some(1.0)
2206 } else {
2207 Some(0.0)
2208 }
2209 })
2210 .collect();
2211 let ml = cv_train_predict_lgbm(&feats, &folds, &train_label, cfg);
2216 {
2217 let (mut ps, mut pn, mut ng, mut nn, mut ds, mut dn) =
2218 (0.0f64, 0usize, 0.0f64, 0usize, 0.0f64, 0usize);
2219 for (k, &(_, _, _, _)) in items.iter().enumerate() {
2220 match train_label[k] {
2221 Some(l) if l >= 0.5 => {
2222 ps += ml[k];
2223 pn += 1;
2224 }
2225 Some(_) => {
2226 ng += ml[k];
2227 nn += 1;
2228 }
2229 None => {
2230 ds += ml[k];
2231 dn += 1;
2232 }
2233 }
2234 }
2235 let mut rk: Vec<(f64, bool)> = items
2238 .iter()
2239 .enumerate()
2240 .map(|(k, &(_, decoy, _, _))| (ml[k], decoy))
2241 .collect();
2242 rk.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());
2243 let (mut t, mut d, mut depth) = (0usize, 0usize, 0usize);
2244 for &(_, decoy) in &rk {
2245 if decoy {
2246 d += 1;
2247 } else {
2248 t += 1;
2249 }
2250 if t > 0 && (d as f64 / t as f64) <= 0.01 {
2251 depth = t;
2252 }
2253 }
2254 log::info!("ML diag: conf_mean={:.3} dim_mean={:.3} DECOY_mean={:.3} (conf={} dim={} decoy={}); target peptides @1% pep-FDR = {}",
2255 ps / pn.max(1) as f64, ng / nn.max(1) as f64, ds / dn.max(1) as f64, pn, nn, dn, depth);
2256 if let Ok(path) = std::env::var("UNO_DUMP_ML") {
2257 use std::io::Write;
2258 if let Ok(f) = std::fs::File::create(&path) {
2259 let mut w = std::io::BufWriter::new(f);
2260 let _ = writeln!(
2262 w,
2263 "label\tdecoy\tml\tpeptide\tproteins\t{}",
2264 (0..feats[0].len())
2265 .map(|i| format!("f{i}"))
2266 .collect::<Vec<_>>()
2267 .join("\t")
2268 );
2269 for (k, &(pi, decoy, _, _)) in items.iter().enumerate() {
2270 let lab = match train_label[k] {
2271 Some(l) if l >= 0.5 => 1,
2272 Some(_) => 0,
2273 None => -1,
2274 };
2275 let pep = &db.peptides[pi];
2276 let prots: Vec<&str> =
2277 pep.proteins.iter().map(|p| p.as_ref()).collect();
2278 let _ = writeln!(
2279 w,
2280 "{}\t{}\t{:.5}\t{}\t{}\t{}",
2281 lab,
2282 decoy as u8,
2283 ml[k],
2284 pep.sequence.as_ref(),
2285 prots.join(";"),
2286 feats[k]
2287 .iter()
2288 .map(|x| format!("{x:.4}"))
2289 .collect::<Vec<_>>()
2290 .join("\t")
2291 );
2292 }
2293 log::info!("dumped {} ML rows to {}", items.len(), path);
2294 }
2295 }
2296 }
2297 drop(feats);
2301 let pep_feat_ml: rustc_hash::FxHashMap<usize, Vec<usize>> = items
2315 .iter()
2316 .map(|&(pi, _, pos, _)| {
2317 let best_fi = pfms[reduced[pos]].feature_idx as usize;
2318 let mut features = vec![best_fi];
2319 if cfg.matrix_joint_alternative_groups {
2320 if let Some(groups) = &matrix.joint_evidence {
2321 let pr = best_cal
2322 .apply(abstract_rt[pos].expect("joint candidate requires RT"));
2323 for &(_, other) in &pep_pfms[&pi] {
2324 let fi = pfms[reduced[other]].feature_idx as usize;
2325 if fi == best_fi {
2326 continue;
2327 }
2328 if crate::joint::best_deviation(
2329 &groups[fi],
2330 db.peptides[pi].mass,
2331 pr,
2332 best_shift,
2333 best_sigma,
2334 rt_sig_ml,
2335 best_gate,
2336 )
2337 .is_some_and(|d| d <= 9.0)
2338 {
2339 features.push(fi);
2340 }
2341 }
2342 features.sort_unstable();
2343 features.dedup();
2344 }
2345 }
2346 (pi, features)
2347 })
2348 .collect();
2349 let ens_by = std::env::var("UNO_ENS_BY").unwrap_or_default();
2359 let mut rows: Vec<(f64, bool, usize)> = match ens_by.as_str() {
2360 "dev" => items
2362 .iter()
2363 .map(|&(pi, decoy, _, dev)| (-dev, decoy, pi))
2364 .collect(),
2365 "mix" => {
2368 let n = items.len().max(1) as f64;
2369 let mut by_ml: Vec<usize> = (0..items.len()).collect();
2371 by_ml.sort_by(|&a, &b| ml[b].partial_cmp(&ml[a]).unwrap());
2372 let mut ml_rank = vec![0f64; items.len()];
2373 for (r, &k) in by_ml.iter().enumerate() {
2374 ml_rank[k] = r as f64 / n;
2375 }
2376 let mut by_dev: Vec<usize> = (0..items.len()).collect();
2378 by_dev.sort_by(|&a, &b| items[a].3.partial_cmp(&items[b].3).unwrap());
2379 let mut dev_rank = vec![0f64; items.len()];
2380 for (r, &k) in by_dev.iter().enumerate() {
2381 dev_rank[k] = r as f64 / n;
2382 }
2383 items
2384 .iter()
2385 .enumerate()
2386 .map(|(k, &(pi, decoy, _, _))| (-(ml_rank[k] + dev_rank[k]), decoy, pi))
2387 .collect()
2388 }
2389 _ => items
2390 .iter()
2391 .enumerate()
2392 .map(|(k, &(pi, decoy, _, _))| (ml[k], decoy, pi))
2393 .collect(),
2394 };
2395 rows.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap().then(a.2.cmp(&b.2)));
2396 let n = rows.len();
2397 let n_steps: usize = std::env::var("UNO_ENS_STEPS")
2418 .ok()
2419 .and_then(|s| s.parse().ok())
2420 .unwrap_or(40);
2421 let lo: usize = std::env::var("UNO_ENS_LO")
2422 .ok()
2423 .and_then(|s| s.parse().ok())
2424 .unwrap_or(1)
2425 .max(1);
2426 let hi: usize = std::env::var("UNO_ENS_HI")
2427 .ok()
2428 .and_then(|s| s.parse().ok())
2429 .unwrap_or(14)
2430 .min(n_steps);
2431 let mut n_used = (hi + 1).saturating_sub(lo).max(1);
2432 let decoy_cut = std::env::var("UNO_ML_DECOYCUT").is_ok();
2439 let ml_corr: f64 = std::env::var("UNO_ML_CORR")
2440 .ok()
2441 .and_then(|s| s.parse().ok())
2442 .unwrap_or(0.5);
2443 let steps: Vec<usize> = if decoy_cut {
2444 const NB: usize = 50;
2445 let mut qval_ok = 0usize;
2446 for b in 0..NB {
2447 let (lo_i, hi_i) = (b * n / NB, ((b + 1) * n / NB).max(b * n / NB + 1).min(n));
2448 if lo_i >= n {
2449 break;
2450 }
2451 let bin = &rows[lo_i..hi_i];
2452 let dfrac =
2453 bin.iter().filter(|(_, d, _)| *d).count() as f64 / bin.len().max(1) as f64;
2454 if dfrac < ml_corr {
2455 qval_ok = b;
2456 } else {
2457 break;
2458 }
2459 }
2460 let keep = ((qval_ok + 1) * n / NB).max(1).min(n);
2461 n_used = 1;
2462 vec![keep]
2463 } else {
2464 (lo..=hi)
2465 .map(|step| (n * step / n_steps).max(1).min(n))
2466 .collect()
2467 };
2468 let diagnostic = cfg.diagnostic_peptides_path.as_ref().map(|_| {
2469 let rank_by: HashMap<usize, usize> = rows
2470 .iter()
2471 .enumerate()
2472 .map(|(i, &(_, _, pi))| (pi, i))
2473 .collect();
2474 let max_keep = steps.iter().copied().max().unwrap_or(0);
2475 items
2476 .iter()
2477 .enumerate()
2478 .map(|(i, &(pi, decoy, pos, dev))| {
2479 let rank = rank_by[&pi];
2480 (
2481 pi,
2482 pfms[reduced[pos]].feature_idx as usize,
2483 decoy,
2484 ml[i],
2485 dev,
2486 (rank + 1) as f64 / n as f64,
2487 rank < max_keep,
2488 )
2489 })
2490 .collect()
2491 });
2492 let per_step: Vec<Vec<ProteinId>> = steps
2497 .par_iter()
2498 .map(|&keep| {
2499 let mut observed: rustc_hash::FxHashMap<
2500 (bool, &str),
2501 rustc_hash::FxHashSet<usize>,
2502 > = rustc_hash::FxHashMap::default();
2503 for &(_, decoy, pi) in &rows[..keep] {
2504 for prot in &db.peptides[pi].proteins {
2505 observed
2506 .entry((decoy, prot.as_ref()))
2507 .or_default()
2508 .insert(pi);
2509 }
2510 }
2511 crate::protein::score_proteins_feature_compete(
2512 observed,
2513 &pep_feat_ml,
2514 &theo,
2515 decoy_theo_sum,
2516 0.01,
2517 )
2518 })
2519 .collect();
2520 let mut acc: HashMap<(bool, String), (f64, usize, usize)> = HashMap::new();
2521 for scored in &per_step {
2522 for p in scored {
2523 let e = acc
2524 .entry((p.decoy, p.accession.clone()))
2525 .or_insert((0.0, 0, 0));
2526 e.0 += p.binomial_score;
2527 e.1 = e.1.max(p.observed_peptides);
2528 e.2 = p.theoretical_peptides;
2529 }
2530 }
2531 let mut ensembled: Vec<ProteinId> = acc
2534 .into_iter()
2535 .map(|((decoy, accession), (s, obs, theo))| ProteinId {
2536 accession,
2537 decoy,
2538 observed_peptides: obs,
2539 theoretical_peptides: theo,
2540 coverage: if theo > 0 {
2541 obs as f64 / theo as f64
2542 } else {
2543 0.0
2544 },
2545 binomial_score: s / n_used as f64,
2546 mean_rt_diff: 0.0,
2547 mean_mass_z: 0.0,
2548 mean_pep_score: 0.0,
2549 q_value: 1.0,
2550 })
2551 .collect();
2552 ensembled.sort_by(|a, b| {
2553 b.binomial_score
2554 .partial_cmp(&a.binomial_score)
2555 .unwrap()
2556 .then_with(|| a.accession.cmp(&b.accession))
2557 .then(a.decoy.cmp(&b.decoy))
2558 });
2559 let score_by: HashMap<(bool, String), f64> = ensembled
2560 .iter()
2561 .map(|p| ((p.decoy, p.accession.clone()), p.binomial_score))
2562 .collect();
2563 ensembled.retain(|p| match score_by.get(&(!p.decoy, p.accession.clone())) {
2564 Some(&cs) => p.binomial_score > cs || (p.binomial_score == cs && !p.decoy),
2565 None => true,
2566 });
2567 let (mut t, mut d) = (0usize, 0usize);
2568 for p in ensembled.iter_mut() {
2569 if p.decoy {
2570 d += 1;
2571 } else {
2572 t += 1;
2573 }
2574 p.q_value = if t == 0 {
2575 1.0
2576 } else {
2577 (d as f64 + 1.0) / t as f64
2578 };
2579 }
2580 let mut run = f64::INFINITY;
2581 for i in (0..ensembled.len()).rev() {
2582 run = run.min(ensembled[i].q_value);
2583 ensembled[i].q_value = run.min(1.0);
2584 }
2585 let ml_n = ensembled
2586 .iter()
2587 .filter(|p| !p.decoy && p.q_value <= 0.01)
2588 .count();
2589 log::info!("mass-anchor ML qpred-ensemble: {} peptides ranked, {}-step cumulative sweep; {} confident proteins (vs {} deviation)",
2590 n, n_used, ml_n, best_n);
2591 return Some(FinalizedCandidate {
2600 proteins: ensembled,
2601 n_confident: ml_n,
2602 peptides,
2603 diagnostic,
2604 ml_selected: true,
2605 });
2606 }
2607 None
2608 };
2609
2610 let finalize = |r: &StageRun, fl: f64, mu: f64| -> FinalizedCandidate {
2613 set_rt_floor(fl);
2614 rt_sig_mult.set(mu);
2615 match run_ml(&r.2, &r.3, r.4, r.5, r.6, r.0) {
2616 Some(mut result) => {
2617 let take = if smooth {
2618 soft_depth(&result.proteins) > soft_depth(&r.2)
2619 } else {
2620 result.n_confident > r.0
2621 };
2622 if !take {
2623 result.ml_selected = false;
2626 result.proteins = r.2.clone();
2627 result.n_confident = r.0;
2628 }
2629 result
2630 }
2631 None => FinalizedCandidate {
2632 proteins: r.2.clone(),
2633 n_confident: r.0,
2634 peptides: Vec::new(),
2635 diagnostic: None,
2636 ml_selected: false,
2637 },
2638 }
2639 };
2640 let chosen_idx = if multi_cand {
2641 let mut finals = Vec::new();
2642 for (i, (r, fl, mu)) in runs.iter().enumerate() {
2643 let f = finalize(r, *fl, *mu);
2644 log::info!("mass-anchor candidate {} (floor {:.1}, sigma x{:.1}): final {} proteins at q<=1%, soft depth {:.1}", i, fl, mu, f.n_confident, soft_depth(&f.proteins));
2645 finals.push(f);
2646 }
2647 let (best_i, result) =
2648 select_finalized_candidate(finals, smooth_level == 3, retain_zero_discovery_evidence);
2649 (best_i, Some(result))
2650 } else {
2651 let mut best_i = 0usize;
2652 for (i, (r, _, _)) in runs.iter().enumerate() {
2653 if r.0 > runs[best_i].0 .0 {
2654 best_i = i;
2655 }
2656 }
2657 (best_i, None)
2658 };
2659 let (best, fl, mu) = runs.swap_remove(chosen_idx.0);
2660 set_rt_floor(fl);
2661 rt_sig_mult.set(mu);
2662 let (
2663 best_n,
2664 best_cut,
2665 best_proteins,
2666 best_cal,
2667 best_shift,
2668 best_sigma,
2669 best_gate,
2670 conf1,
2671 _rt_sig1,
2672 _best_soft,
2673 ) = best;
2674 let FinalizedCandidate {
2675 proteins: best_proteins,
2676 n_confident: best_n,
2677 peptides,
2678 diagnostic,
2679 ml_selected,
2680 } = match chosen_idx.1 {
2681 Some(f) => f,
2682 None => finalize(
2683 &(
2684 best_n,
2685 best_cut,
2686 best_proteins.clone(),
2687 best_cal.clone(),
2688 best_shift,
2689 best_sigma,
2690 best_gate,
2691 conf1.clone(),
2692 _rt_sig1,
2693 _best_soft,
2694 ),
2695 fl,
2696 mu,
2697 ),
2698 };
2699 log::info!(
2700 "mass-anchor: {} confident target proteins (q<=1%, min-deviation any-feature, dev_cut={:.0})",
2701 best_n, best_cut
2702 );
2703 if let Some(path) = &cfg.diagnostic_peptides_path {
2704 use std::io::Write;
2705 let mut w = std::io::BufWriter::new(
2706 std::fs::File::create(path).expect("cannot create selected-peptide diagnostic"),
2707 );
2708 writeln!(w, "peptide\tdecoy\tproteins\tfeature_idx\tml\tdeviation\trank_fraction\tin_ensemble\tml_selected\tgroup_anchor_run\tgroup_anchor_observation").unwrap();
2709 for (pi, fi, decoy, ml, dev, rank, included) in diagnostic.into_iter().flatten() {
2710 let pep = &db.peptides[pi];
2711 let prots = pep
2712 .proteins
2713 .iter()
2714 .map(|p| p.as_ref())
2715 .collect::<Vec<_>>()
2716 .join(";");
2717 let anchor = matrix.joint_evidence.as_ref().map(|g| &g[fi][0]);
2718 writeln!(
2719 w,
2720 "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
2721 pep.sequence,
2722 u8::from(decoy),
2723 prots,
2724 fi,
2725 ml,
2726 dev,
2727 rank,
2728 u8::from(included),
2729 u8::from(ml_selected),
2730 anchor.map(|o| o.run_id.to_string()).unwrap_or_default(),
2731 anchor
2732 .map(|o| o.observation_id.to_string())
2733 .unwrap_or_default()
2734 )
2735 .unwrap();
2736 }
2737 w.flush().expect("cannot flush selected-peptide diagnostic");
2738 }
2739 MASS_ANCHOR_PEPTIDES.with(|c| *c.borrow_mut() = peptides);
2742
2743 MASS_ANCHOR_PROTEINS.with(|c| *c.borrow_mut() = best_proteins);
2744 (
2745 cal,
2746 if anchor_pos.len() >= 50 {
2747 anchor_pos
2748 } else {
2749 clean
2750 },
2751 shift,
2752 sigma,
2753 )
2754}
2755
2756thread_local! {
2757 static MASS_ANCHOR_PROTEINS: std::cell::RefCell<Vec<crate::protein::ProteinId>> =
2760 const { std::cell::RefCell::new(Vec::new()) };
2761 static MASS_ANCHOR_PEPTIDES: std::cell::RefCell<Vec<(u32, u32, bool)>> =
2767 const { std::cell::RefCell::new(Vec::new()) };
2768}
2769
2770#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2772pub struct PeptideHit {
2773 pub peptide_idx: u32,
2774 pub feature_idx: u32,
2775 pub decoy: bool,
2776}
2777
2778fn reduce_top_k(pfms: &[Pfm], n_features: usize, k: usize) -> Vec<usize> {
2780 let mut by_feature: Vec<(Vec<usize>, Vec<usize>)> = vec![(Vec::new(), Vec::new()); n_features];
2782 for (i, p) in pfms.iter().enumerate() {
2783 let bucket = if p.decoy {
2784 &mut by_feature[p.feature_idx as usize].1
2785 } else {
2786 &mut by_feature[p.feature_idx as usize].0
2787 };
2788 bucket.push(i);
2789 }
2790 let mut kept = Vec::new();
2791 for (targets, decoys) in by_feature.iter_mut() {
2792 for bucket in [targets, decoys] {
2793 bucket.sort_by(|&a, &b| {
2794 pfms[a]
2795 .mass_error_ppm
2796 .abs()
2797 .partial_cmp(&pfms[b].mass_error_ppm.abs())
2798 .unwrap()
2799 });
2800 bucket.truncate(k);
2801 kept.extend_from_slice(bucket);
2802 }
2803 }
2804 kept
2805}
2806
2807fn lgbm_fold(
2824 feats: &[Vec<f32>],
2825 folds: &[usize],
2826 train_label: &[Option<f64>],
2827 params: &str,
2828 iters: usize,
2829 test_fold: usize,
2830 rank_norm: bool,
2831 scores: &mut [f64],
2832) {
2833 let (mut pos_rows, mut neg_rows) = (Vec::new(), Vec::new());
2834 for p in 0..feats.len() {
2835 if folds[p] == test_fold {
2836 continue;
2837 }
2838 match train_label[p] {
2839 Some(l) if l >= 0.5 => pos_rows.push(p),
2840 Some(_) => neg_rows.push(p),
2841 None => {}
2842 }
2843 }
2844 if pos_rows.is_empty() || neg_rows.is_empty() {
2845 return;
2846 }
2847 let neg_cap = (pos_rows.len() * 3).max(20_000);
2848 if neg_rows.len() > neg_cap {
2849 neg_rows.sort_by_key(|&p| {
2850 let mut x = (p as u64).wrapping_mul(0x9e3779b97f4a7c15);
2851 x ^= x >> 29;
2852 x
2853 });
2854 neg_rows.truncate(neg_cap);
2855 }
2856 let train_feats: Vec<Vec<f32>> = pos_rows
2857 .iter()
2858 .chain(neg_rows.iter())
2859 .map(|&p| feats[p].clone())
2860 .collect();
2861 let train_labels: Vec<f32> = pos_rows
2862 .iter()
2863 .map(|_| 1.0f32)
2864 .chain(neg_rows.iter().map(|_| 0.0f32))
2865 .collect();
2866 let model =
2867 match uno_lightgbm_sys::LgbmBooster::train(&train_feats, &train_labels, params, iters) {
2868 Ok(m) => m,
2869 Err(e) => {
2870 log::warn!("LightGBM fold {test_fold} training failed: {e}");
2871 return;
2872 }
2873 };
2874 let test_idx: Vec<usize> = (0..feats.len())
2875 .filter(|&p| folds[p] == test_fold)
2876 .collect();
2877 if test_idx.is_empty() {
2878 return;
2879 }
2880 let test_feats: Vec<Vec<f32>> = test_idx.iter().map(|&p| feats[p].clone()).collect();
2881 let preds = match model.predict(&test_feats) {
2882 Ok(p) => p,
2883 Err(e) => {
2884 log::warn!("LightGBM fold {test_fold} prediction failed: {e}");
2885 return;
2886 }
2887 };
2888 if rank_norm {
2889 let n = preds.len();
2891 let mut order: Vec<usize> = (0..n).collect();
2892 order.sort_by(|&a, &b| preds[a].partial_cmp(&preds[b]).unwrap().then(a.cmp(&b)));
2893 let mut rn = vec![0.0f64; n];
2894 for (rank, &k) in order.iter().enumerate() {
2895 rn[k] = (rank + 1) as f64 / n as f64;
2896 }
2897 for (k, &p) in test_idx.iter().enumerate() {
2898 scores[p] = rn[k];
2899 }
2900 } else {
2901 for (k, &p) in test_idx.iter().enumerate() {
2902 scores[p] = preds[k] as f64;
2903 }
2904 }
2905}
2906
2907fn sequoia_fold(
2913 feats: &[Vec<f32>],
2914 folds: &[usize],
2915 train_label: &[Option<f64>],
2916 depth: u32,
2917 lr: f64,
2918 iters: usize,
2919 test_fold: usize,
2920 rank_norm: bool,
2921 seed: u64,
2922 scores: &mut [f64],
2923) {
2924 use sequoia_boost::prelude::*;
2925 let (mut pos_rows, mut neg_rows) = (Vec::new(), Vec::new());
2926 for p in 0..feats.len() {
2927 if folds[p] == test_fold {
2928 continue;
2929 }
2930 match train_label[p] {
2931 Some(l) if l >= 0.5 => pos_rows.push(p),
2932 Some(_) => neg_rows.push(p),
2933 None => {}
2934 }
2935 }
2936 if pos_rows.is_empty() || neg_rows.is_empty() {
2937 return;
2938 }
2939 let neg_cap = (pos_rows.len() * 3).max(20_000);
2940 if neg_rows.len() > neg_cap {
2941 neg_rows.sort_by_key(|&p| {
2942 let mut x = (p as u64).wrapping_mul(0x9e3779b97f4a7c15);
2943 x ^= x >> 29;
2944 x
2945 });
2946 neg_rows.truncate(neg_cap);
2947 }
2948 let ncol = feats.first().map(|f| f.len()).unwrap_or(0);
2949 if ncol == 0 {
2950 return;
2951 }
2952 let train_rows: Vec<usize> = pos_rows.iter().chain(neg_rows.iter()).copied().collect();
2953 let mut flat: Vec<f32> = Vec::with_capacity(train_rows.len() * ncol);
2954 for &p in &train_rows {
2955 flat.extend_from_slice(&feats[p]);
2956 }
2957 let labels: Vec<f32> = pos_rows
2958 .iter()
2959 .map(|_| 1.0f32)
2960 .chain(neg_rows.iter().map(|_| 0.0f32))
2961 .collect();
2962 let dtrain = match DMatrix::from_dense(&flat, train_rows.len(), ncol)
2963 .and_then(|d| d.with_labels(&labels))
2964 {
2965 Ok(d) => d,
2966 Err(e) => {
2967 log::warn!("sequoia fold {test_fold} DMatrix failed: {e}");
2968 return;
2969 }
2970 };
2971 let ev = |k: &str, d: f64| {
2974 std::env::var(k)
2975 .ok()
2976 .and_then(|s| s.parse().ok())
2977 .unwrap_or(d)
2978 };
2979 let max_bin = ev("UNO_SEQ_BINS", 256.0) as usize;
2980 let lambda = ev("UNO_SEQ_LAMBDA", 1.0);
2981 let mcw = ev("UNO_SEQ_MCW", 80.0);
2982 let alpha = ev("UNO_SEQ_ALPHA", 0.0);
2983 let gamma = ev("UNO_SEQ_GAMMA", 0.0);
2984 let subsample = ev("UNO_SEQ_SUB", 0.8);
2985 let colsample = ev("UNO_SEQ_COL", 0.8);
2986 let params = match TrainingParams::builder()
2987 .objective("binary:logistic")
2988 .tree_method(TreeMethod::Hist)
2989 .max_depth(depth as usize)
2990 .eta(lr)
2991 .subsample(subsample)
2992 .colsample_bytree(colsample)
2993 .min_child_weight(mcw)
2994 .lambda(lambda)
2995 .alpha(alpha)
2996 .gamma(gamma)
2997 .max_bin(max_bin)
2998 .nthread(1) .seed(42 ^ crate::seed_mix(seed))
3000 .build()
3001 {
3002 Ok(p) => p,
3003 Err(e) => {
3004 log::warn!("sequoia fold {test_fold} params failed: {e}");
3005 return;
3006 }
3007 };
3008 let model = match train(¶ms, &dtrain, iters) {
3009 Ok(m) => m,
3010 Err(e) => {
3011 log::warn!("sequoia fold {test_fold} training failed: {e}");
3012 return;
3013 }
3014 };
3015 let test_idx: Vec<usize> = (0..feats.len())
3016 .filter(|&p| folds[p] == test_fold)
3017 .collect();
3018 if test_idx.is_empty() {
3019 return;
3020 }
3021 let mut tflat: Vec<f32> = Vec::with_capacity(test_idx.len() * ncol);
3022 for &p in &test_idx {
3023 tflat.extend_from_slice(&feats[p]);
3024 }
3025 let dtest = match DMatrix::from_dense(&tflat, test_idx.len(), ncol) {
3026 Ok(d) => d,
3027 Err(e) => {
3028 log::warn!("sequoia fold {test_fold} test DMatrix failed: {e}");
3029 return;
3030 }
3031 };
3032 let preds = match model.predict(&dtest) {
3033 Ok(p) => p,
3034 Err(e) => {
3035 log::warn!("sequoia fold {test_fold} prediction failed: {e}");
3036 return;
3037 }
3038 };
3039 if rank_norm {
3040 let n = preds.len();
3041 let mut order: Vec<usize> = (0..n).collect();
3042 order.sort_by(|&a, &b| preds[a].partial_cmp(&preds[b]).unwrap().then(a.cmp(&b)));
3043 let mut rn = vec![0.0f64; n];
3044 for (rank, &k) in order.iter().enumerate() {
3045 rn[k] = (rank + 1) as f64 / n as f64;
3046 }
3047 for (k, &p) in test_idx.iter().enumerate() {
3048 scores[p] = rn[k];
3049 }
3050 } else {
3051 for (k, &p) in test_idx.iter().enumerate() {
3052 scores[p] = preds[k] as f64;
3053 }
3054 }
3055}
3056
3057fn lgbm_auc(scores: &[f64], train_label: &[Option<f64>]) -> f64 {
3060 let mut pairs: Vec<(f64, bool)> = Vec::new();
3061 for i in 0..scores.len() {
3062 match train_label[i] {
3063 Some(l) if l >= 0.5 => pairs.push((scores[i], true)),
3064 Some(_) => pairs.push((scores[i], false)),
3065 None => {}
3066 }
3067 }
3068 pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
3069 let n = pairs.len();
3070 let (mut rank_sum_pos, mut n_pos, mut n_neg) = (0.0f64, 0usize, 0usize);
3071 let mut i = 0;
3072 while i < n {
3073 let mut j = i;
3074 while j < n && pairs[j].0 == pairs[i].0 {
3075 j += 1;
3076 }
3077 let avg_rank = ((i + 1 + j) as f64) / 2.0;
3078 for k in i..j {
3079 if pairs[k].1 {
3080 rank_sum_pos += avg_rank;
3081 n_pos += 1;
3082 } else {
3083 n_neg += 1;
3084 }
3085 }
3086 i = j;
3087 }
3088 if n_pos == 0 || n_neg == 0 {
3089 return 0.5;
3090 }
3091 (rank_sum_pos - (n_pos as f64) * (n_pos as f64 + 1.0) / 2.0) / (n_pos as f64 * n_neg as f64)
3092}
3093
3094fn lgbm_sample_params(t: u64, seed: u64) -> (String, usize) {
3097 let lgb_seed = 42u64.wrapping_add(seed);
3098 let mut s = t
3099 .wrapping_mul(0x9e3779b97f4a7c15)
3100 .wrapping_add(0x2545f4914f6cdd1d)
3101 ^ crate::seed_mix(seed);
3102 let mut u = || {
3103 s = s
3104 .wrapping_mul(6364136223846793005)
3105 .wrapping_add(1442695040888963407);
3106 (s >> 33) as f64 / (1u64 << 31) as f64
3107 };
3108 let num_leaves = (15.0 + u() * 485.0) as u32; let lr = 0.01 + u() * 0.19; let min_data = (20.0 + u() * 480.0) as usize; let ff = 0.4 + u() * 0.6; let bf = 0.5 + u() * 0.5; let l1 = u(); let l2 = u(); let iters = 100usize;
3116 let p = format!(
3117 "objective=binary num_leaves={num_leaves} learning_rate={lr:.4} \
3118 verbosity=-1 min_data_in_leaf={min_data} feature_fraction={ff:.3} bagging_fraction={bf:.3} bagging_freq=1 \
3119 lambda_l1={l1:.3} lambda_l2={l2:.3} \
3120 num_threads=1 deterministic=true force_row_wise=true seed={lgb_seed} \
3121 feature_fraction_seed={lgb_seed} bagging_seed={lgb_seed} data_random_seed={lgb_seed}"
3122 );
3123 (p, iters)
3124}
3125
3126fn cv_train_predict_lgbm(
3127 feats: &[Vec<f32>],
3128 folds: &[usize],
3129 train_label: &[Option<f64>],
3130 cfg: &ScoreConfig,
3131) -> Vec<f64> {
3132 let n = feats.len();
3133 let n_trials: usize = std::env::var("UNO_ML_TUNE")
3143 .ok()
3144 .and_then(|s| s.parse().ok())
3145 .unwrap_or(0);
3146 let depth = std::env::var("UNO_ML_DEPTH")
3148 .ok()
3149 .and_then(|s| s.parse().ok())
3150 .unwrap_or(cfg.max_depth.max(7));
3151 if n_trials == 0 {
3152 let iters = std::env::var("UNO_ML_ITERS")
3153 .ok()
3154 .and_then(|s| s.parse().ok())
3155 .unwrap_or(300usize);
3156 let num_leaves = std::env::var("UNO_ML_LEAVES")
3157 .ok()
3158 .and_then(|s| s.parse().ok())
3159 .unwrap_or((1u32 << depth.min(8)).saturating_sub(1).max(2));
3160 let lr: f64 = std::env::var("UNO_ML_LR")
3161 .ok()
3162 .and_then(|s| s.parse().ok())
3163 .unwrap_or(cfg.shrinkage.min(0.05) as f64);
3164 let rank = std::env::var("UNO_ML_RANK")
3165 .ok()
3166 .map(|s| s != "0")
3167 .unwrap_or(false);
3168 if !std::env::var("UNO_ML_LIGHTGBM").is_ok() {
3174 let fold_scores: Vec<Vec<f64>> = (0..cfg.n_folds)
3179 .into_par_iter()
3180 .map(|f| {
3181 let mut s = vec![0.0f64; n];
3182 sequoia_fold(
3183 feats,
3184 folds,
3185 train_label,
3186 depth,
3187 lr,
3188 iters,
3189 f,
3190 rank,
3191 cfg.effective_classifier_seed(),
3192 &mut s,
3193 );
3194 s
3195 })
3196 .collect();
3197 let mut scores = vec![0.0f64; n];
3198 for i in 0..n {
3199 scores[i] = fold_scores[folds[i]][i];
3200 }
3201 return scores;
3202 }
3203 let lgb_seed = 42u64.wrapping_add(cfg.effective_classifier_seed());
3204 let params = format!(
3205 "objective=binary num_leaves={num_leaves} max_depth={depth} learning_rate={lr} \
3206 verbosity=-1 min_data_in_leaf=20 feature_fraction=0.8 bagging_fraction=0.8 bagging_freq=1 \
3207 num_threads=1 deterministic=true force_row_wise=true seed={lgb_seed} \
3208 feature_fraction_seed={lgb_seed} bagging_seed={lgb_seed} data_random_seed={lgb_seed}");
3209 let fold_scores: Vec<Vec<f64>> = (0..cfg.n_folds)
3217 .into_par_iter()
3218 .map(|f| {
3219 let mut s = vec![0.0f64; n];
3220 lgbm_fold(feats, folds, train_label, ¶ms, iters, f, rank, &mut s);
3221 s
3222 })
3223 .collect();
3224 let mut scores = vec![0.0f64; n];
3225 for i in 0..n {
3226 scores[i] = fold_scores[folds[i]][i];
3227 }
3228 return scores;
3229 }
3230 let mut best: Option<(f64, String, usize)> = None;
3233 for t in 0..n_trials as u64 {
3234 let (params, iters) = lgbm_sample_params(t, cfg.effective_classifier_seed());
3235 let mut sc = vec![0.0f64; n];
3236 lgbm_fold(feats, folds, train_label, ¶ms, iters, 0, false, &mut sc);
3237 let auc = lgbm_auc(
3238 &sc,
3239 &train_label
3240 .iter()
3241 .enumerate()
3242 .map(|(i, l)| if folds[i] == 0 { *l } else { None })
3243 .collect::<Vec<_>>(),
3244 );
3245 if best.as_ref().is_none_or(|(b, _, _)| auc > *b) {
3246 best = Some((auc, params, iters));
3247 }
3248 }
3249 let (best_auc, params, iters) = best.unwrap_or_else(|| {
3250 let lgb_seed = 42u64.wrapping_add(cfg.effective_classifier_seed());
3251 (0.0, format!("objective=binary num_leaves=127 learning_rate=0.05 verbosity=-1 min_data_in_leaf=20 \
3252 feature_fraction=0.8 bagging_fraction=0.8 bagging_freq=1 num_threads=1 deterministic=true \
3253 force_row_wise=true seed={lgb_seed} feature_fraction_seed={lgb_seed} bagging_seed={lgb_seed} data_random_seed={lgb_seed}"), 100)
3254 });
3255 log::info!(
3256 "ML hyperparam search: {} trials, best fold-0 AUC = {:.4}",
3257 n_trials,
3258 best_auc
3259 );
3260 let mut scores = vec![0.0f64; n];
3262 for f in 0..cfg.n_folds {
3263 lgbm_fold(
3264 feats,
3265 folds,
3266 train_label,
3267 ¶ms,
3268 iters,
3269 f,
3270 true,
3271 &mut scores,
3272 );
3273 }
3274 scores
3275}
3276
3277fn robust_sigma(vals: impl Iterator<Item = f64>) -> f64 {
3281 let mut v: Vec<f64> = vals.filter(|x| x.is_finite()).map(|x| x.abs()).collect();
3282 if v.is_empty() {
3283 return f64::INFINITY;
3284 }
3285 v.sort_by(|a, b| a.partial_cmp(b).unwrap());
3286 let mad = v[v.len() / 2];
3287 (mad / 0.6745).max(1e-9)
3289}
3290
3291fn clipped_sigma(vals: impl Iterator<Item = f64>) -> f64 {
3296 let mut v: Vec<f64> = vals.filter(|x| x.is_finite()).collect();
3297 v.sort_unstable_by(f64::total_cmp);
3304 if v.len() < 20 {
3305 return robust_sigma(v.into_iter());
3306 }
3307 let mut sig = robust_sigma(v.iter().copied());
3308 for _ in 0..8 {
3309 let core: Vec<f64> = v
3310 .iter()
3311 .copied()
3312 .filter(|&x| x.abs() <= 2.5 * sig)
3313 .collect();
3314 if core.len() < 20 {
3315 break;
3316 }
3317 let mean = core.iter().sum::<f64>() / core.len() as f64;
3318 let var = core.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / core.len() as f64;
3319 let new_sig = var.sqrt().max(1e-9);
3320 if (new_sig - sig).abs() < 1e-4 {
3321 sig = new_sig;
3322 break;
3323 }
3324 sig = new_sig;
3325 }
3326 sig
3327}
3328
3329fn noisygaus_fit(vals: &[f64], span: f64, nbins: usize) -> (f64, f64) {
3337 let v: Vec<f64> = vals
3338 .iter()
3339 .copied()
3340 .filter(|x| x.is_finite() && x.abs() < span)
3341 .collect();
3342 if v.len() < 40 {
3343 return (0.0, robust_sigma(vals.iter().copied()));
3344 }
3345 let mut m = v.clone();
3346 m.sort_by(|a, b| a.partial_cmp(b).unwrap());
3347 let med = m[m.len() / 2];
3348 let bw = 2.0 * span / nbins as f64;
3349 let mut hist = vec![0.0f64; nbins];
3350 for &x in &v {
3351 let b = (((x - med + span) / bw) as usize).min(nbins - 1);
3352 hist[b] += 1.0;
3353 }
3354 let centers: Vec<f64> = (0..nbins)
3355 .map(|i| med - span + (i as f64 + 0.5) * bw)
3356 .collect();
3357 let n = nbins as f64;
3359 let mut best = (f64::INFINITY, med, 1.0f64);
3360 let mut x0 = med - 3.0;
3361 while x0 <= med + 3.0 {
3362 let mut s = 0.15;
3363 while s <= span * 0.4 {
3364 let (mut sg, mut sgg, mut sh, mut sgh) = (0.0, 0.0, 0.0, 0.0);
3366 for i in 0..nbins {
3367 let g = (-((centers[i] - x0).powi(2)) / (2.0 * s * s)).exp();
3368 sg += g;
3369 sgg += g * g;
3370 sh += hist[i];
3371 sgh += g * hist[i];
3372 }
3373 let det = sgg * n - sg * sg;
3374 if det.abs() > 1e-9 {
3375 let a = (sgh * n - sg * sh) / det;
3376 let b = (sgg * sh - sg * sgh) / det;
3377 if a > 0.0 {
3378 let mut sse = 0.0;
3379 for i in 0..nbins {
3380 let g = (-((centers[i] - x0).powi(2)) / (2.0 * s * s)).exp();
3381 let r = hist[i] - (a * g + b);
3382 sse += r * r;
3383 }
3384 if sse < best.0 {
3385 best = (sse, x0, s);
3386 }
3387 }
3388 }
3389 s += 0.1;
3390 }
3391 x0 += bw;
3392 }
3393 (best.1, best.2.max(0.05))
3394}
3395
3396fn gaussian_core_sigma(vals: impl Iterator<Item = f64>, span: f64) -> f64 {
3403 let v: Vec<f64> = vals.filter(|x| x.is_finite()).collect();
3404 if v.len() < 40 {
3405 return robust_sigma(v.into_iter());
3406 }
3407 let mut m = v.clone();
3409 m.sort_by(|a, b| a.partial_cmp(b).unwrap());
3410 let med = m[m.len() / 2];
3411 const NB: usize = 201; let bw = 2.0 * span / NB as f64;
3413 let mut hist = vec![0u32; NB];
3414 for &x in &v {
3415 let d = x - med;
3416 if d.abs() >= span {
3417 continue;
3418 }
3419 let b = (((d + span) / bw) as usize).min(NB - 1);
3420 hist[b] += 1;
3421 }
3422 let sm: Vec<f64> = (0..NB)
3424 .map(|i| {
3425 let lo = i.saturating_sub(1);
3426 let hi = (i + 1).min(NB - 1);
3427 (hist[lo] + hist[i] + hist[hi]) as f64 / (hi - lo + 1) as f64
3428 })
3429 .collect();
3430 let peak_idx = (0..NB)
3431 .max_by(|&a, &b| sm[a].partial_cmp(&sm[b]).unwrap())
3432 .unwrap();
3433 let peak = sm[peak_idx];
3434 let mut tails: Vec<f64> = Vec::new();
3436 for i in 0..NB {
3437 if !(NB * 3 / 10..=NB * 7 / 10).contains(&i) {
3438 tails.push(sm[i]);
3439 }
3440 }
3441 tails.sort_by(|a, b| a.partial_cmp(b).unwrap());
3442 let baseline = tails[tails.len() / 2];
3443 let half = (peak + baseline) / 2.0;
3444 if peak <= baseline + 1.0 {
3445 return clipped_sigma(v.into_iter());
3446 }
3447 let mut l = peak_idx;
3449 while l > 0 && sm[l] > half {
3450 l -= 1;
3451 }
3452 let mut r = peak_idx;
3453 while r < NB - 1 && sm[r] > half {
3454 r += 1;
3455 }
3456 let hwhm = ((r - l) as f64 / 2.0) * bw;
3457 (hwhm / 1.1774).max(0.05) }
3459
3460pub fn score(
3463 matrix: &FeatureMatrix,
3464 db: &PeptideDb,
3465 pfms: &[Pfm],
3466 rt_model: Option<&RtModel>,
3467 im_model: Option<&crate::im::ImModel>,
3468 rt_library: Option<&std::collections::HashMap<String, f64>>,
3469 cfg: &ScoreConfig,
3470) -> ScoreResult {
3471 let n_features = matrix.features.len();
3472 let reduced = reduce_top_k(pfms, n_features, cfg.top_k);
3473 log::info!(
3474 "reduced {} PFMs to {} (top-{} per feature)",
3475 pfms.len(),
3476 reduced.len(),
3477 cfg.top_k
3478 );
3479
3480 let abstract_rt: Vec<Option<f64>> = if let Some(lib) = rt_library {
3488 let mut hits = 0usize;
3489 let v: Vec<Option<f64>> = reduced
3490 .iter()
3491 .map(|&i| {
3492 let seq = db.peptides[pfms[i].peptide_idx as usize].sequence.as_ref();
3493 let r = lib.get(seq).copied();
3494 if r.is_some() {
3495 hits += 1;
3496 }
3497 r
3498 })
3499 .collect();
3500 log::info!(
3501 "abstract RT from DeepLC library: {}/{} reduced PFMs covered",
3502 hits,
3503 reduced.len()
3504 );
3505 v
3506 } else {
3507 match rt_model {
3508 Some(m) => {
3509 let t = std::time::Instant::now();
3510 let mut uniq: Vec<usize> = reduced
3514 .iter()
3515 .map(|&i| pfms[i].peptide_idx as usize)
3516 .collect();
3517 uniq.sort_unstable();
3518 uniq.dedup();
3519 let seqs: Vec<&str> = uniq
3520 .iter()
3521 .map(|&pi| db.peptides[pi].sequence.as_ref())
3522 .collect();
3523 let upreds = crate::rt_cache::predict_many_cached(m, &seqs);
3524 let pred_of: std::collections::HashMap<usize, f64> =
3525 uniq.iter().copied().zip(upreds).collect();
3526 log::info!(
3527 "predicted abstract RT for {} unique peptides ({} PFMs) ({:.1}s)",
3528 uniq.len(),
3529 reduced.len(),
3530 t.elapsed().as_secs_f64()
3531 );
3532 reduced
3533 .par_iter()
3534 .map(|&i| pred_of.get(&(pfms[i].peptide_idx as usize)).copied())
3535 .collect()
3536 }
3537 None => vec![None; reduced.len()],
3538 }
3539 };
3540
3541 let has_rt = abstract_rt.iter().any(|a| a.is_some());
3547 if has_rt && cfg.ml_rescore && matrix.joint_evidence.is_none() {
3551 if let Some(model) = im_model {
3552 let charges: Vec<u8> = matrix
3553 .features
3554 .iter()
3555 .filter_map(|f| {
3556 (f.charge > 0 && f.im.is_some_and(|v| v.is_finite() && v > 0.0))
3557 .then_some(f.charge)
3558 })
3559 .collect();
3560 if !charges.is_empty() {
3561 let seqs: Vec<&str> = db.peptides.iter().map(|p| p.sequence.as_ref()).collect();
3562 model.precompute_library(&seqs, &charges);
3563 }
3564 }
3565 }
3566 let run_anchor = |broad_rt_bootstrap, retain_zero| {
3567 MASS_ANCHOR_PROTEINS.with(|c| c.borrow_mut().clear());
3570 MASS_ANCHOR_PEPTIDES.with(|c| c.borrow_mut().clear());
3571 mass_anchor_calibration(
3572 &reduced,
3573 pfms,
3574 matrix,
3575 db,
3576 &abstract_rt,
3577 cfg,
3578 retain_zero,
3579 broad_rt_bootstrap,
3580 im_model,
3581 );
3582 let proteins = MASS_ANCHOR_PROTEINS.with(|c| std::mem::take(&mut *c.borrow_mut()));
3583 let peptides = MASS_ANCHOR_PEPTIDES.with(|c| {
3584 std::mem::take(&mut *c.borrow_mut())
3585 .into_iter()
3586 .map(|(peptide_idx, feature_idx, decoy)| PeptideHit {
3587 peptide_idx,
3588 feature_idx,
3589 decoy,
3590 })
3591 .collect()
3592 });
3593 ScoreResult { proteins, peptides }
3594 };
3595 if !has_rt {
3596 log::warn!("no RT model or RT library available; uno requires retention time -- no proteins produced");
3597 }
3598 run_recovery_bootstraps(has_rt, run_anchor)
3599}
3600
3601#[cfg(test)]
3602mod determinism_tests {
3603 use super::*;
3604
3605 fn candidate(n: usize, feature: u32, q: f64) -> FinalizedCandidate {
3606 FinalizedCandidate {
3607 proteins: (0..n)
3608 .map(|i| ProteinId {
3609 accession: format!("candidate_{feature}_{i}"),
3610 decoy: false,
3611 observed_peptides: 1,
3612 theoretical_peptides: 1,
3613 coverage: 1.0,
3614 binomial_score: 1.0,
3615 mean_rt_diff: 0.0,
3616 mean_mass_z: 0.0,
3617 mean_pep_score: 0.0,
3618 q_value: q,
3619 })
3620 .collect(),
3621 n_confident: if q <= 0.01 { n } else { 0 },
3622 peptides: vec![(7, feature, false)],
3623 diagnostic: None,
3624 ml_selected: false,
3625 }
3626 }
3627
3628 #[test]
3629 fn zero_discovery_cuts_retain_usable_training_evidence() {
3630 let weak = candidate(5, 10, 0.20).proteins;
3631 let useful = candidate(30, 20, 0.05).proteins;
3632 let mut decoys = candidate(100, 30, 0.005).proteins;
3633 for p in &mut decoys {
3634 p.decoy = true;
3635 }
3636 let cuts = [Vec::new(), weak, useful, decoys];
3637 let i = select_deviation_cut(cuts.iter().map(Vec::as_slice), true).unwrap();
3638 assert_eq!(i, 2);
3639 assert_eq!(
3640 cuts[i]
3641 .iter()
3642 .filter(|p| !p.decoy && p.q_value <= 0.01)
3643 .count(),
3644 0
3645 );
3646 assert_eq!(
3647 cuts[i]
3648 .iter()
3649 .filter(|p| !p.decoy && p.q_value <= 0.25)
3650 .count(),
3651 30
3652 );
3653 assert_eq!(select_deviation_cut(std::iter::empty(), true), None);
3654 assert_eq!(
3656 select_deviation_cut(cuts.iter().map(Vec::as_slice), false),
3657 None
3658 );
3659 }
3660
3661 #[test]
3662 fn discovery_cut_selection_preserves_positive_counts_and_first_ties() {
3663 let training_only = candidate(1000, 10, 0.20).proteins;
3664 let discoveries = candidate(100, 20, 0.01).proteins;
3665 let mut tied = discoveries.clone();
3666 tied.extend(candidate(500, 30, 0.05).proteins);
3667 let cuts = [training_only, discoveries, tied];
3668 assert_eq!(
3669 select_deviation_cut(cuts.iter().map(Vec::as_slice), true),
3670 Some(1)
3671 );
3672 assert_eq!(
3673 select_deviation_cut(cuts.iter().map(Vec::as_slice), false),
3674 Some(1)
3675 );
3676 let empty = [Vec::new(), Vec::new()];
3677 assert_eq!(
3678 select_deviation_cut(empty.iter().map(Vec::as_slice), true),
3679 Some(0)
3680 );
3681 }
3682
3683 #[test]
3686 fn finalized_candidate_retains_its_own_peptide_evidence() {
3687 for count_only in [false, true] {
3688 let (i, winner) = select_finalized_candidate(
3689 vec![candidate(3, 10, 0.005), candidate(1, 20, 0.005)],
3690 count_only,
3691 true,
3692 );
3693 assert_eq!(i, 0);
3694 assert_eq!(winner.n_confident, 3);
3695 assert_eq!(winner.proteins[0].accession, "candidate_10_0");
3696 assert_eq!(winner.peptides, vec![(7, 10, false)]);
3697 }
3698 }
3699
3700 #[test]
3701 fn training_only_evidence_cannot_displace_confident_identifications() {
3702 let training = candidate(300, 10, 0.02);
3705 let confident = candidate(117, 20, 0.01);
3706 assert!(soft_depth(&training.proteins) > soft_depth(&confident.proteins));
3707 let (i, winner) = select_finalized_candidate(vec![training, confident], false, true);
3708 assert_eq!(i, 1);
3709 assert_eq!(winner.n_confident, 117);
3710 assert_eq!(winner.peptides, vec![(7, 20, false)]);
3711 let (i, winner) = select_finalized_candidate(
3714 vec![candidate(100, 10, 0.02), candidate(300, 20, 0.02)],
3715 false,
3716 true,
3717 );
3718 assert_eq!(i, 1);
3719 assert_eq!(winner.n_confident, 0);
3720 }
3721
3722 #[test]
3723 fn original_pass_preserves_legacy_soft_depth_selection() {
3724 let (i, winner) = select_finalized_candidate(
3727 vec![candidate(300, 10, 0.02), candidate(117, 20, 0.01)],
3728 false,
3729 false,
3730 );
3731 assert_eq!(i, 0);
3732 assert_eq!(winner.n_confident, 0);
3733 }
3734
3735 fn recovery_result(n: usize, feature: u32, q: f64) -> ScoreResult {
3736 let c = candidate(n, feature, q);
3737 ScoreResult {
3738 proteins: c.proteins,
3739 peptides: c
3740 .peptides
3741 .into_iter()
3742 .map(|(peptide_idx, feature_idx, decoy)| PeptideHit {
3743 peptide_idx,
3744 feature_idx,
3745 decoy,
3746 })
3747 .collect(),
3748 }
3749 }
3750
3751 #[test]
3752 fn successful_original_search_never_invokes_recovery() {
3753 let result = recover_zero_discoveries(recovery_result(117, 10, 0.01), || {
3754 panic!("a successful original ranking must not compete against rescue candidates")
3755 });
3756 assert_eq!(result.proteins.len(), 117);
3757 assert_eq!(result.peptides[0].feature_idx, 10);
3758 }
3759
3760 #[test]
3761 fn failed_recovery_preserves_original_lower_confidence_evidence() {
3762 let mut calls = 0;
3763 let result = recover_zero_discoveries(recovery_result(30, 10, 0.03), || {
3764 calls += 1;
3765 recovery_result(300, 20, 0.02)
3766 });
3767 assert_eq!(calls, 1);
3768 assert_eq!(result.proteins.len(), 30);
3769 assert_eq!(result.peptides[0].feature_idx, 10);
3770 }
3771
3772 #[test]
3773 fn recovery_requires_actual_target_discoveries_and_keeps_their_map() {
3774 let mut original = recovery_result(30, 10, 0.005);
3775 for p in &mut original.proteins {
3776 p.decoy = true;
3777 }
3778 let result = recover_zero_discoveries(original, || recovery_result(100, 20, 0.01));
3779 assert_eq!(result.proteins.len(), 100);
3780 assert_eq!(result.peptides[0].feature_idx, 20);
3781 let mut decoys = recovery_result(300, 30, 0.005);
3782 for p in &mut decoys.proteins {
3783 p.decoy = true;
3784 }
3785 let result = recover_zero_discoveries(recovery_result(30, 10, 0.03), || decoys);
3786 assert_eq!(result.peptides[0].feature_idx, 10);
3787 }
3788
3789 #[test]
3790 fn rt_recovery_preserves_successful_initial_v3_lazily() {
3791 let mut calls = Vec::new();
3792 let result = run_recovery_bootstraps(true, |broad, retain| {
3793 calls.push((broad, retain));
3794 assert_eq!(
3795 (broad, retain),
3796 (false, false),
3797 "successful V3 must not run another pass"
3798 );
3799 recovery_result(117, 10, 0.01)
3800 });
3801 assert_eq!(calls, [(false, false)]);
3802 assert_eq!(result.proteins.len(), 117);
3803 assert_eq!(result.peptides[0].feature_idx, 10);
3804 }
3805
3806 #[test]
3807 fn rt_recovery_waits_for_complete_v3_retention_policy() {
3808 let mut calls = Vec::new();
3809 let result = run_recovery_bootstraps(true, |broad, retain| {
3810 assert!(
3811 !broad,
3812 "V3 retention success must prevent broad RT from running"
3813 );
3814 calls.push((broad, retain));
3815 if retain {
3816 recovery_result(50, 20, 0.01)
3817 } else {
3818 recovery_result(30, 10, 0.02)
3819 }
3820 });
3821 assert_eq!(calls, [(false, false), (false, true)]);
3822 assert_eq!(result.proteins.len(), 50);
3823 assert_eq!(result.peptides[0].feature_idx, 20);
3824 }
3825
3826 #[test]
3827 fn rt_recovery_requires_actual_targets_and_never_runs_broad_retention() {
3828 let mut calls = Vec::new();
3829 let result = run_recovery_bootstraps(true, |broad, retain| {
3830 calls.push((broad, retain));
3831 match (broad, retain) {
3832 (false, false) => {
3833 let mut result = recovery_result(300, 10, 0.001);
3834 for protein in &mut result.proteins {
3835 protein.decoy = true;
3836 }
3837 result
3838 }
3839 (false, true) => recovery_result(400, 20, 0.02),
3840 (true, false) => recovery_result(25, 40, 0.01),
3841 (true, true) => panic!("bounded recovery must never run broad retention"),
3842 }
3843 });
3844 assert_eq!(calls, [(false, false), (false, true), (true, false)]);
3845 assert_eq!(result.proteins.len(), 25);
3846 assert_eq!(result.peptides[0].feature_idx, 40);
3847 }
3848
3849 #[test]
3850 fn failed_broad_recovery_preserves_original_v3_proteins_and_peptides() {
3851 let mut calls = Vec::new();
3852 let result = run_recovery_bootstraps(true, |broad, retain| {
3853 calls.push((broad, retain));
3854 let feature = 10 + 10 * calls.len() as u32;
3855 let mut result = recovery_result(
3856 if broad { 1000 } else { 30 },
3857 feature,
3858 if broad { 0.001 } else { 0.03 },
3859 );
3860 if broad {
3861 for protein in &mut result.proteins {
3862 protein.decoy = true;
3863 }
3864 }
3865 result
3866 });
3867 assert_eq!(calls, [(false, false), (false, true), (true, false)]);
3868 assert_eq!(result.proteins.len(), 30);
3869 assert!(result
3870 .proteins
3871 .iter()
3872 .all(|p| !p.decoy && p.q_value == 0.03));
3873 assert_eq!(result.proteins[0].accession, "candidate_20_0");
3874 assert_eq!(result.peptides[0].feature_idx, 20);
3875 }
3876
3877 #[test]
3878 fn rt_recovery_without_predictions_never_runs_any_bootstrap() {
3879 let result = run_recovery_bootstraps(false, |_, _| {
3880 panic!("no RT must remain an early empty result")
3881 });
3882 assert!(result.proteins.is_empty());
3883 assert!(result.peptides.is_empty());
3884 }
3885
3886 #[test]
3887 fn bounded_recovery_policy_repeats_identically_despite_fresh_timing() {
3888 let mut expected = None;
3889 for _ in 0..16 {
3890 let mut calls = Vec::new();
3891 let result = run_recovery_bootstraps(true, |broad, retain| {
3892 calls.push((broad, retain));
3893 assert!(!(broad && retain));
3894 recovery_result(
3895 if broad { 11 } else { 30 },
3896 if broad { 40 } else { 10 },
3897 if broad { 0.01 } else { 0.02 },
3898 )
3899 });
3900 let signature = (
3901 calls,
3902 result
3903 .proteins
3904 .iter()
3905 .map(|p| (p.accession.clone(), p.q_value.to_bits(), p.decoy))
3906 .collect::<Vec<_>>(),
3907 result
3908 .peptides
3909 .iter()
3910 .map(|p| (p.peptide_idx, p.feature_idx, p.decoy))
3911 .collect::<Vec<_>>(),
3912 );
3913 if let Some(ref prior) = expected {
3914 assert_eq!(&signature, prior);
3915 } else {
3916 expected = Some(signature);
3917 }
3918 }
3919 }
3920
3921 #[test]
3924 fn finalized_candidate_ties_and_later_winners_keep_matching_evidence() {
3925 for count_only in [false, true] {
3926 let (i, tie) = select_finalized_candidate(
3927 vec![candidate(2, 10, 0.005), candidate(2, 20, 0.005)],
3928 count_only,
3929 true,
3930 );
3931 assert_eq!(i, 0);
3932 assert_eq!(tie.peptides, vec![(7, 10, false)]);
3933 let (i, later) = select_finalized_candidate(
3934 vec![candidate(1, 10, 0.005), candidate(3, 20, 0.005)],
3935 count_only,
3936 true,
3937 );
3938 assert_eq!(i, 1);
3939 assert_eq!(later.proteins[0].accession, "candidate_20_0");
3940 assert_eq!(later.peptides, vec![(7, 20, false)]);
3941 }
3942 }
3943
3944 #[test]
3952 fn clipped_sigma_is_order_invariant() {
3953 let base: Vec<f64> = (0..400)
3956 .map(|i| {
3957 let x = (i % 41) as f64 - 20.0;
3958 if i % 17 == 0 {
3959 x * 3.7
3960 } else {
3961 x * 0.11
3962 }
3963 })
3964 .collect();
3965
3966 let want = clipped_sigma(base.iter().copied());
3967
3968 for stride in [7usize, 13, 29, 97] {
3970 let mut shuffled = Vec::with_capacity(base.len());
3971 let mut i = 0usize;
3972 for _ in 0..base.len() {
3973 shuffled.push(base[i]);
3974 i = (i + stride) % base.len();
3975 }
3976 shuffled.reverse();
3977 let got = clipped_sigma(shuffled.into_iter());
3978 assert_eq!(
3979 want.to_bits(),
3980 got.to_bits(),
3981 "clipped_sigma changed with input order (stride {stride}): {want} vs {got}"
3982 );
3983 }
3984 }
3985
3986 #[test]
3988 fn clipped_sigma_is_order_invariant_below_the_gate() {
3989 let base: Vec<f64> = vec![0.4, -1.2, 0.03, 2.9, -0.7, 0.11, -0.02, 1.4, 0.8, -0.35];
3990 let want = clipped_sigma(base.iter().copied());
3991 let mut rev = base.clone();
3992 rev.reverse();
3993 assert_eq!(want.to_bits(), clipped_sigma(rev.into_iter()).to_bits());
3994 }
3995}
3996
3997#[cfg(test)]
3998mod stage_seed_tests {
3999 use super::*;
4000
4001 #[test]
4002 fn old_configs_preserve_every_scoring_stream() {
4003 for seed in [0, 1, 42, u64::MAX] {
4004 let cfg: ScoreConfig =
4005 serde_json::from_value(serde_json::json!({ "seed": seed })).unwrap();
4006 assert_eq!(cfg.effective_rt_seed(), seed);
4007 assert_eq!(cfg.effective_cv_seed(), seed);
4008 assert_eq!(cfg.effective_classifier_seed(), seed);
4009 let old_folds: Vec<_> = (0..100).map(|i| fold_of(i, 3, seed)).collect();
4010 let new_folds: Vec<_> = (0..100)
4011 .map(|i| fold_of(i, 3, cfg.effective_cv_seed()))
4012 .collect();
4013 assert_eq!(old_folds, new_folds);
4014 assert_eq!(
4015 lgbm_sample_params(3, seed),
4016 lgbm_sample_params(3, cfg.effective_classifier_seed())
4017 );
4018 }
4019 }
4020
4021 #[test]
4022 fn explicit_zero_pins_stages_across_parent_seeds() {
4023 let mut cfg: ScoreConfig = serde_json::from_value(serde_json::json!({
4024 "seed": 9, "rt_seed": 0, "cv_seed": 0, "classifier_seed": 0,
4025 }))
4026 .unwrap();
4027 for seed in [0, 1, 9, u64::MAX] {
4028 cfg.seed = seed;
4029 assert_eq!(cfg.effective_rt_seed(), 0);
4030 assert_eq!(cfg.effective_cv_seed(), 0);
4031 assert_eq!(cfg.effective_classifier_seed(), 0);
4032 }
4033 }
4034
4035 #[test]
4036 fn stage_overrides_are_independent() {
4037 let mut cfg = ScoreConfig {
4038 seed: 7,
4039 ..ScoreConfig::default()
4040 };
4041 cfg.rt_seed = Some(11);
4042 assert_eq!(
4043 (
4044 cfg.effective_rt_seed(),
4045 cfg.effective_cv_seed(),
4046 cfg.effective_classifier_seed()
4047 ),
4048 (11, 7, 7)
4049 );
4050 cfg.cv_seed = Some(13);
4051 assert_eq!(
4052 (
4053 cfg.effective_rt_seed(),
4054 cfg.effective_cv_seed(),
4055 cfg.effective_classifier_seed()
4056 ),
4057 (11, 13, 7)
4058 );
4059 cfg.classifier_seed = Some(17);
4060 assert_eq!(
4061 (
4062 cfg.effective_rt_seed(),
4063 cfg.effective_cv_seed(),
4064 cfg.effective_classifier_seed()
4065 ),
4066 (11, 13, 17)
4067 );
4068 let original_folds: Vec<_> = (0..100).map(|i| fold_of(i, 3, 7)).collect();
4069 let changed_folds: Vec<_> = (0..100)
4070 .map(|i| fold_of(i, 3, cfg.effective_cv_seed()))
4071 .collect();
4072 assert_ne!(original_folds, changed_folds);
4073 assert_ne!(
4074 lgbm_sample_params(3, 7),
4075 lgbm_sample_params(3, cfg.effective_classifier_seed())
4076 );
4077 }
4078}