Skip to main content

uno_core/
protein.rs

1//! Phase 6: protein-level aggregation + FDR.
2//!
3//! MS1 PSM-level scores are weak — random peptides matching in mass+RT are
4//! hard to separate from real ones. But real peptides cluster into proteins
5//! (several consistent peptides each), while random matches scatter as
6//! singletons across random proteins. Aggregating to proteins and scoring each
7//! by a binomial surprise (how unlikely its peptide count is by chance)
8//! concentrates the signal. This is how DirectMS1/ms1searchpy reach depth.
9
10use rustc_hash::{FxHashMap, FxHashSet};
11use std::cmp::Ordering;
12use std::collections::BinaryHeap;
13
14/// One candidate in the greedy-parsimony max-heap: a protein `key`, its score,
15/// and the peptide count `n_obs` the score was computed at. `n_obs` is a staleness
16/// stamp — peptide counts only ever shrink, so an entry whose stored `n_obs` no
17/// longer matches the protein's current count is outdated and skipped on pop.
18/// Ordering reproduces the old `max_by` exactly: by score, ties broken by key.
19struct GreedyEntry<'a> {
20    score: f64,
21    key: (bool, &'a str),
22    n_obs: usize,
23}
24impl PartialEq for GreedyEntry<'_> {
25    fn eq(&self, other: &Self) -> bool {
26        self.cmp(other) == Ordering::Equal
27    }
28}
29impl Eq for GreedyEntry<'_> {}
30impl Ord for GreedyEntry<'_> {
31    fn cmp(&self, other: &Self) -> Ordering {
32        // scores are finite (binom tail clamped to [1e-300,1]), so total_cmp
33        // matches the old partial_cmp().unwrap(); key breaks ties identically.
34        self.score
35            .total_cmp(&other.score)
36            .then_with(|| self.key.cmp(&other.key))
37    }
38}
39impl PartialOrd for GreedyEntry<'_> {
40    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
41        Some(self.cmp(other))
42    }
43}
44
45#[derive(Debug, Clone, serde::Deserialize)]
46#[serde(default)]
47pub struct ProteinConfig {
48    /// A peptide counts as "observed" if its winning feature match has
49    /// |rt_diff| within this window (minutes). Applied equally to targets and
50    /// decoys, so the decoy-estimated hit rate stays calibrated.
51    pub rt_window: f64,
52    /// Additional ion-mobility consistency window (1/K0). When the data has IM,
53    /// an observed peptide must also have |im_diff| within this window. IM is
54    /// orthogonal to RT, so this cuts random coincidences sharply — the main
55    /// reason ion mobility boosts MS1 depth. Ignored on non-IM data.
56    pub im_window: f64,
57    /// Recalibrated-mass window in sigma units. An observed peptide must match
58    /// within this many sigma of the per-run mass error. Tight mass is the
59    /// dominant background suppressor (ms1searchpy's mass recalibration).
60    pub mass_z_window: f64,
61    /// Target protein FDR the threshold optimization maximizes proteins at.
62    pub fdr: f64,
63    /// Optimize the RT/mass thresholds to maximize proteins at `fdr` (ms1searchpy's
64    /// `worker` grid scan). When true, rt_window/mass_z_window are upper bounds for
65    /// the scan; when false they are used as fixed cutoffs.
66    pub optimize_thresholds: bool,
67    /// Minimum feature shape quality (n_isotopes) for a peptide to count as
68    /// observed. Real peptides sit on well-formed features; random matches scatter
69    /// onto junk features, so this gate drops the random-match background
70    /// asymmetrically. The scan tries [0, this] — 0 disables it. Inert when the
71    /// input has no shape descriptors (feat_quality == 0 everywhere).
72    pub max_feature_isotopes_gate: f64,
73    /// Rescore proteins with a cross-validated Fisher LDA over protein-level
74    /// features (binomial score, log#peptides, coverage, RT/mass consistency,
75    /// mean peptide discriminant). Deterministic (closed-form, 3-fold CV by
76    /// accession hash) so it does not reintroduce ms1searchpy-style ML variance.
77    pub ml_rescore: bool,
78}
79
80impl Default for ProteinConfig {
81    fn default() -> Self {
82        ProteinConfig {
83            rt_window: 3.0,
84            im_window: 0.03,
85            mass_z_window: 3.0,
86            fdr: 0.01,
87            optimize_thresholds: true,
88            max_feature_isotopes_gate: 0.0,
89            ml_rescore: false,
90        }
91    }
92}
93
94#[derive(Debug, Clone)]
95pub struct ProteinId {
96    pub accession: String,
97    pub decoy: bool,
98    pub observed_peptides: usize,
99    pub theoretical_peptides: usize,
100    pub coverage: f64,
101    pub binomial_score: f64,
102    /// Mean |rt_diff| of the protein's seen peptides (RT consistency). Real
103    /// proteins cluster near 0; lucky decoy proteins sit near the window edge.
104    pub mean_rt_diff: f64,
105    /// Mean |mass_z| and mean per-peptide discriminant score of seen peptides
106    /// (features for the optional LDA rescorer).
107    pub mean_mass_z: f64,
108    pub mean_pep_score: f64,
109    pub q_value: f64,
110}
111
112/// Lanczos approximation to ln Γ(x).
113fn ln_gamma(x: f64) -> f64 {
114    const G: [f64; 8] = [
115        676.5203681218851,
116        -1259.1392167224028,
117        771.323_428_777_653_1,
118        -176.615_029_162_140_6,
119        12.507343278686905,
120        -0.13857109526572012,
121        9.984_369_578_019_572e-6,
122        1.5056327351493116e-7,
123    ];
124    if x < 0.5 {
125        std::f64::consts::PI / ((std::f64::consts::PI * x).sin() * ln_gamma(1.0 - x).exp())
126    } else {
127        let x = x - 1.0;
128        let mut a = 0.999_999_999_999_809_9;
129        let t = x + 7.5;
130        for (i, &g) in G.iter().enumerate() {
131            a += g / (x + (i as f64) + 1.0);
132        }
133        0.5 * (2.0 * std::f64::consts::PI).ln() + (x + 0.5) * t.ln() - t + a.ln()
134    }
135}
136
137/// Continued fraction for the regularized incomplete beta (Numerical Recipes).
138fn betacf(a: f64, b: f64, x: f64) -> f64 {
139    let tiny = 1e-30;
140    let qab = a + b;
141    let qap = a + 1.0;
142    let qam = a - 1.0;
143    let mut c = 1.0;
144    let mut d = 1.0 - qab * x / qap;
145    if d.abs() < tiny {
146        d = tiny;
147    }
148    d = 1.0 / d;
149    let mut h = d;
150    for m in 1..200 {
151        let m = m as f64;
152        let m2 = 2.0 * m;
153        let aa = m * (b - m) * x / ((qam + m2) * (a + m2));
154        d = 1.0 + aa * d;
155        if d.abs() < tiny {
156            d = tiny;
157        }
158        c = 1.0 + aa / c;
159        if c.abs() < tiny {
160            c = tiny;
161        }
162        d = 1.0 / d;
163        h *= d * c;
164        let aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2));
165        d = 1.0 + aa * d;
166        if d.abs() < tiny {
167            d = tiny;
168        }
169        c = 1.0 + aa / c;
170        if c.abs() < tiny {
171            c = tiny;
172        }
173        d = 1.0 / d;
174        let del = d * c;
175        h *= del;
176        if (del - 1.0).abs() < 1e-12 {
177            break;
178        }
179    }
180    h
181}
182
183/// Regularized incomplete beta function I_x(a, b).
184pub(crate) fn betai(a: f64, b: f64, x: f64) -> f64 {
185    if x <= 0.0 {
186        return 0.0;
187    }
188    if x >= 1.0 {
189        return 1.0;
190    }
191    let bt = (ln_gamma(a + b) - ln_gamma(a) - ln_gamma(b) + a * x.ln() + b * (1.0 - x).ln()).exp();
192    if x < (a + 1.0) / (a + b + 2.0) {
193        bt * betacf(a, b, x) / a
194    } else {
195        1.0 - bt * betacf(b, a, 1.0 - x) / b
196    }
197}
198
199/// Exact binomial upper tail P(X >= k) for X ~ Binomial(n, p), via the
200/// regularized incomplete beta: P(X >= k) = I_p(k, n-k+1). This is ms1searchpy's
201/// `binom.sf(k-1, n, p)`. The Poisson approximation it replaces is only valid for
202/// small p; here p (the per-peptide random hit rate) is 5-20%, where Poisson is
203/// ~1/(1-p) too wide and under-scores every protein.
204pub(crate) fn binom_sf_ge_k(k: u32, n: u32, p: f64) -> f64 {
205    if k == 0 {
206        return 1.0;
207    }
208    if k as u64 > n as u64 {
209        return 0.0;
210    }
211    betai(k as f64, (n - k + 1) as f64, p).clamp(1e-300, 1.0)
212}
213
214/// Continuous generalization of [`binom_sf_ge_k`]: the regularized incomplete beta
215/// `I_p(k, n-k+1)` accepts a fractional `k`, so a WEIGHTED observed count (the sum of
216/// per-peptide posterior probabilities) plugs straight into the same binomial tail
217/// the discrete scorer uses. No Poisson approximation (which is too wide at the
218/// p=5-20% random hit-rate). Reduces to `binom_sf_ge_k` exactly when `k` is integral.
219pub(crate) fn binom_sf_ge_k_cont(k: f64, n: f64, p: f64) -> f64 {
220    if k <= 0.0 {
221        return 1.0;
222    }
223    if k >= n + 1.0 {
224        return 0.0;
225    }
226    betai(k, (n - k + 1.0).max(1e-9), p).clamp(1e-300, 1.0)
227}
228
229/// Continuous-evidence protein scorer: the single-pass replacement for the
230/// deviation-cut SWEEP. Instead of a hard `dev <= cut` gate (whose optimal cut is
231/// found by re-running inference ~30x), each peptide carries a posterior weight
232/// `w in [0,1]` = P(correct | dev) from the target/decoy `dev` distributions, and a
233/// protein's evidence is the WEIGHTED sum of its (competed) peptides. Same machinery
234/// as [`score_proteins_feature_compete`] otherwise: feature-level competition
235/// (banning peaks), dynamic decoy background rate, greedy parsimony, target/decoy
236/// pair competition + (d+1)/t FDR. `weight`: peptide index -> posterior.
237pub(crate) fn score_proteins_weighted(
238    observed: FxHashMap<(bool, &str), FxHashSet<usize>>,
239    pep_feat: &FxHashMap<usize, Vec<usize>>,
240    weight: &FxHashMap<usize, f64>,
241    theoretical: &FxHashMap<(bool, &str), u32>,
242    decoy_theo: u64,
243) -> Vec<ProteinId> {
244    let theo_of = |key: &(bool, &str)| theoretical.get(key).copied().unwrap_or(0) as usize;
245    let w_of = |pi: usize| weight.get(&pi).copied().unwrap_or(0.0);
246    let prot_peps = observed;
247    let mut feat_peps: FxHashMap<usize, FxHashSet<usize>> = FxHashMap::default();
248    let mut all_peps: FxHashSet<usize> = FxHashSet::default();
249    for peps in prot_peps.values() {
250        for &pi in peps {
251            all_peps.insert(pi);
252        }
253    }
254    let mut rem_feat: FxHashMap<usize, usize> = FxHashMap::default();
255    for &pi in &all_peps {
256        if let Some(fs) = pep_feat.get(&pi) {
257            for &f in fs {
258                feat_peps.entry(f).or_default().insert(pi);
259            }
260            rem_feat.insert(pi, fs.len().max(1));
261        }
262    }
263    let mut pep_prots: FxHashMap<usize, Vec<(bool, &str)>> = FxHashMap::default();
264    for (&key, peps) in &prot_peps {
265        for &pi in peps {
266            pep_prots.entry(pi).or_default().push(key);
267        }
268    }
269    // Weighted observed evidence per protein (sum of member peptides' posteriors).
270    let mut prot_peps = prot_peps;
271    let mut obs_w: FxHashMap<(bool, &str), f64> = prot_peps
272        .iter()
273        .map(|(&k, v)| (k, v.iter().map(|&pi| w_of(pi)).sum()))
274        .collect();
275    let mut by_n: FxHashMap<usize, Vec<(bool, &str)>> = FxHashMap::default();
276    for &k in prot_peps.keys() {
277        let n = theo_of(&k);
278        if n > 0 {
279            by_n.entry(n).or_default().push(k);
280        }
281    }
282    let decoy_theo_f = decoy_theo.max(1) as f64;
283    let mut decoy_obs_sum: f64 = obs_w.iter().filter(|((d, _), _)| *d).map(|(_, &c)| c).sum();
284
285    // Best (highest weighted evidence) protein of a given theoretical size; ties by
286    // accession key for determinism.
287    fn best_for_n<'a>(
288        n: usize,
289        by_n: &FxHashMap<usize, Vec<(bool, &'a str)>>,
290        obs_w: &FxHashMap<(bool, &'a str), f64>,
291    ) -> Option<(bool, &'a str)> {
292        by_n.get(&n).and_then(|ks| {
293            ks.iter()
294                .filter(|k| obs_w.get(*k).copied().unwrap_or(0.0) > 1e-9)
295                .max_by(|a, b| {
296                    let (wa, wb) = (obs_w[*a], obs_w[*b]);
297                    wa.partial_cmp(&wb).unwrap().then_with(|| b.cmp(a))
298                })
299                .copied()
300        })
301    }
302    let mut best_per_n: FxHashMap<usize, (bool, &str)> = FxHashMap::default();
303    for &n in by_n.keys() {
304        if let Some(k) = best_for_n(n, &by_n, &obs_w) {
305            best_per_n.insert(n, k);
306        }
307    }
308
309    let mut proteins: Vec<ProteinId> = Vec::new();
310    let (mut t, mut d) = (0usize, 0usize);
311    let mut dead: FxHashSet<usize> = FxHashSet::default();
312    let mut banned_feat: FxHashSet<usize> = FxHashSet::default();
313    loop {
314        let p = (decoy_obs_sum / decoy_theo_f).clamp(1e-9, 0.999);
315        let mut best: Option<((bool, &str), f64, f64, usize)> = None;
316        for (&n, &k) in &best_per_n {
317            let kobs = obs_w.get(&k).copied().unwrap_or(0.0);
318            if kobs <= 1e-9 {
319                continue;
320            }
321            let s = -binom_sf_ge_k_cont(kobs, n as f64, p).log10();
322            let better = match best {
323                None => true,
324                Some((bk, bs, _, _)) => s > bs || (s == bs && k < bk),
325            };
326            if better {
327                best = Some((k, s, kobs, n));
328            }
329        }
330        let (key, score, kobs, n) = match best {
331            Some(x) => x,
332            None => break,
333        };
334        let (decoy, acc) = key;
335        if decoy {
336            d += 1;
337        } else {
338            t += 1;
339        }
340        let q = if t == 0 { 1.0 } else { d as f64 / t as f64 };
341        proteins.push(ProteinId {
342            accession: acc.to_string(),
343            decoy,
344            observed_peptides: kobs.round() as usize,
345            theoretical_peptides: n,
346            coverage: if n > 0 { kobs / n as f64 } else { 0.0 },
347            binomial_score: score,
348            mean_rt_diff: 0.0,
349            mean_mass_z: 0.0,
350            mean_pep_score: 0.0,
351            q_value: q,
352        });
353        if t > 20 && q > 0.5 {
354            break;
355        }
356        let won_peps: Vec<usize> = prot_peps
357            .get(&key)
358            .map(|s| s.iter().copied().collect())
359            .unwrap_or_default();
360        let mut to_kill: Vec<usize> = Vec::new();
361        for pi in &won_peps {
362            match pep_feat.get(pi) {
363                Some(fs) if !fs.is_empty() => {
364                    for &f in fs {
365                        if banned_feat.insert(f) {
366                            if let Some(ps) = feat_peps.get(&f) {
367                                for &pj in ps {
368                                    if let Some(r) = rem_feat.get_mut(&pj) {
369                                        if *r > 0 {
370                                            *r -= 1;
371                                            if *r == 0 {
372                                                to_kill.push(pj);
373                                            }
374                                        }
375                                    }
376                                }
377                            }
378                        }
379                    }
380                }
381                _ => to_kill.push(*pi),
382            }
383        }
384        let mut dirty_n: FxHashSet<usize> = FxHashSet::default();
385        for pj in to_kill {
386            if !dead.insert(pj) {
387                continue;
388            }
389            let wj = w_of(pj);
390            if let Some(keys) = pep_prots.get(&pj) {
391                for k2 in keys.clone() {
392                    if let Some(s) = prot_peps.get_mut(&k2) {
393                        if s.remove(&pj) {
394                            if let Some(c) = obs_w.get_mut(&k2) {
395                                *c -= wj;
396                            }
397                            if k2.0 {
398                                decoy_obs_sum -= wj;
399                            }
400                            dirty_n.insert(theo_of(&k2));
401                        }
402                    }
403                }
404            }
405        }
406        obs_w.insert(key, 0.0);
407        if let Some(s) = prot_peps.get_mut(&key) {
408            s.clear();
409        }
410        dirty_n.insert(n);
411        for dn in dirty_n {
412            match best_for_n(dn, &by_n, &obs_w) {
413                Some(k) => {
414                    best_per_n.insert(dn, k);
415                }
416                None => {
417                    best_per_n.remove(&dn);
418                }
419            }
420        }
421    }
422    proteins.sort_by(|a, b| {
423        b.binomial_score
424            .partial_cmp(&a.binomial_score)
425            .unwrap()
426            .then_with(|| a.accession.cmp(&b.accession))
427            .then(a.decoy.cmp(&b.decoy))
428    });
429    let score_by: FxHashMap<(bool, String), f64> = proteins
430        .iter()
431        .map(|p| ((p.decoy, p.accession.clone()), p.binomial_score))
432        .collect();
433    proteins.retain(|p| match score_by.get(&(!p.decoy, p.accession.clone())) {
434        Some(&cs) => p.binomial_score > cs || (p.binomial_score == cs && !p.decoy),
435        None => true,
436    });
437    let plus1 = if std::env::var("UNO_NO_FDR_PLUS1").is_ok() {
438        0.0
439    } else {
440        1.0
441    };
442    let (mut t, mut d) = (0usize, 0usize);
443    for p in proteins.iter_mut() {
444        if p.decoy {
445            d += 1;
446        } else {
447            t += 1;
448        }
449        p.q_value = if t == 0 {
450            1.0
451        } else {
452            (d as f64 + plus1) / t as f64
453        };
454    }
455    let mut run = f64::INFINITY;
456    for i in (0..proteins.len()).rev() {
457        run = run.min(proteins[i].q_value);
458        proteins[i].q_value = run.min(1.0);
459    }
460    proteins
461}
462
463/// ms1searchpy `final_iteration` core, reusable: greedy parsimony (assign each
464/// peptide to its single best-scoring protein, removing it from competitors and
465/// rescoring them) + target/decoy pair deletion + FDR walk. This is what keeps the
466/// protein FDR honest — counting a shared/homologous peptide for every protein it
467/// touches (as a plain per-protein binomial does) inflates spurious proteins (absent
468/// organisms, decoys) and makes the target/decoy FDR optimistic.
469///
470/// `observed`: protein key (is_decoy, accession) -> set of matched peptide indices.
471/// `theoretical`: protein key -> total tryptic peptide count (the binomial `n`).
472/// `decoy_theo`: total decoy theoretical peptides; `decoy_seen`: distinct matched
473/// decoy peptides (together set the random hit-rate `p`). `fdr`: target FDR (for the
474/// early-stop). uno's decoys share their target's accession (differ only in the
475/// decoy flag), so pair deletion compares (decoy, acc) against (!decoy, acc).
476pub(crate) fn score_proteins_greedy(
477    observed: FxHashMap<(bool, &str), FxHashSet<usize>>,
478    theoretical: &FxHashMap<(bool, &str), u32>,
479    decoy_theo: u64,
480    decoy_seen: usize,
481    fdr: f64,
482) -> Vec<ProteinId> {
483    // Decouple the two jobs (UNO_PROT_PERMUTE): decoys estimate ONLY the background
484    // per-peptide hit rate p; the per-protein binomial gives a p-value
485    // (= 10^-score = binom.sf(k-1, n, p)); Benjamini-Hochberg over those p-values
486    // sets the FDR. No target/decoy competition in the FDR step — decoys feed in
487    // solely through p. Valid as long as the binomial p-values are ~uniform under
488    // the null (checked against the yeast entrapment).
489    let permute = std::env::var("UNO_PROT_PERMUTE").is_ok();
490    let hit_rate = if decoy_theo == 0 {
491        1e-9
492    } else {
493        (decoy_seen as f64 / decoy_theo as f64).max(1e-9)
494    };
495    if std::env::var("UNO_LOG_HITRATE").is_ok() {
496        log::info!(
497            "score_proteins_greedy: hit_rate(p)={:.4} decoy_seen={} decoy_theo={}",
498            hit_rate,
499            decoy_seen,
500            decoy_theo
501        );
502    }
503    let mut score_cache: FxHashMap<(u32, usize), f64> = FxHashMap::default();
504    let mut score_of = |key: &(bool, &str), n_obs: usize| -> f64 {
505        if n_obs == 0 {
506            return 0.0;
507        }
508        let theo = theoretical.get(key).copied().unwrap_or(0);
509        *score_cache
510            .entry((theo, n_obs))
511            .or_insert_with(|| -binom_sf_ge_k(n_obs as u32, theo, hit_rate).log10())
512    };
513    let mut prot_peps = observed;
514    let mut pep_prots: FxHashMap<usize, Vec<(bool, &str)>> = FxHashMap::default();
515    for (&key, peps) in &prot_peps {
516        for &pi in peps {
517            pep_prots.entry(pi).or_default().push(key);
518        }
519    }
520
521    // ms1searchpy-style DYNAMIC background rate `p` (worker/final_iteration). The
522    // static `hit_rate` above freezes the random-match probability at its initial
523    // value; ms1searchpy instead RE-ESTIMATES p = (current decoy matches)/(decoy
524    // theoretical) after every greedy pick. As real proteins consume their
525    // peptides, decoy matches shrink, p drops, and the next real protein's binomial
526    // becomes more significant -- a positive feedback that digs much deeper. This is
527    // the engine difference behind ms1searchpy's ~2.8x depth -- BUT only when paired
528    // with ms1searchpy's FEATURE-level competition (each MS1 peak supports one
529    // protein). uno competes at the PEPTIDE level here (the peak->peptide identity is
530    // already gone), so a real protein winning barely shrinks the decoy pool and the
531    // virtuous cycle never fires: measured -12% honest depth (660 -> 580). Kept behind
532    // UNO_DYNAMIC_P=1 for further work; OFF by default until feature-level competition
533    // is wired through.
534    if std::env::var("UNO_DYNAMIC_P").is_ok() {
535        let theo_of = |key: &(bool, &str)| theoretical.get(key).copied().unwrap_or(0) as usize;
536        // Current observed count per protein, and proteins grouped by theoretical
537        // size n (constant). For a fixed (n, p) the binomial is monotincreasing in
538        // the observed count k, so only the max-k protein per n can ever be the
539        // global best -- we score one representative per n, like ms1searchpy.
540        let mut obs: FxHashMap<(bool, &str), usize> =
541            prot_peps.iter().map(|(&k, v)| (k, v.len())).collect();
542        let mut by_n: FxHashMap<usize, Vec<(bool, &str)>> = FxHashMap::default();
543        for &k in prot_peps.keys() {
544            let n = theo_of(&k);
545            if n > 0 {
546                by_n.entry(n).or_default().push(k);
547            }
548        }
549        let decoy_theo_f = decoy_theo.max(1) as f64;
550        let mut decoy_obs_sum: f64 = obs
551            .iter()
552            .filter(|((d, _), _)| *d)
553            .map(|(_, &c)| c as f64)
554            .sum();
555        fn best_for_n<'a>(
556            n: usize,
557            by_n: &FxHashMap<usize, Vec<(bool, &'a str)>>,
558            obs: &FxHashMap<(bool, &'a str), usize>,
559        ) -> Option<(bool, &'a str)> {
560            by_n.get(&n).and_then(|ks| {
561                ks.iter()
562                    .filter(|k| obs.get(*k).copied().unwrap_or(0) > 0)
563                    .max_by_key(|k| (obs[*k], std::cmp::Reverse(**k)))
564                    .copied()
565            })
566        }
567        let mut best_per_n: FxHashMap<usize, (bool, &str)> = FxHashMap::default();
568        for &n in by_n.keys() {
569            if let Some(k) = best_for_n(n, &by_n, &obs) {
570                best_per_n.insert(n, k);
571            }
572        }
573
574        let mut proteins: Vec<ProteinId> = Vec::new();
575        let (mut t, mut d) = (0usize, 0usize);
576        loop {
577            let p = (decoy_obs_sum / decoy_theo_f).clamp(1e-9, 0.999);
578            // Pick the globally most-significant representative under the current p.
579            let mut best: Option<((bool, &str), f64, usize, usize)> = None;
580            for (&n, &k) in &best_per_n {
581                let kobs = obs.get(&k).copied().unwrap_or(0);
582                if kobs == 0 {
583                    continue;
584                }
585                let s = -binom_sf_ge_k(kobs as u32, n as u32, p).log10();
586                let better = match best {
587                    None => true,
588                    Some((bk, bs, _, _)) => s > bs || (s == bs && k < bk),
589                };
590                if better {
591                    best = Some((k, s, kobs, n));
592                }
593            }
594            let (key, score, kobs, n) = match best {
595                Some(x) => x,
596                None => break,
597            };
598            let (decoy, acc) = key;
599            if decoy {
600                d += 1;
601            } else {
602                t += 1;
603            }
604            let q = if t == 0 { 1.0 } else { d as f64 / t as f64 };
605            proteins.push(ProteinId {
606                accession: acc.to_string(),
607                decoy,
608                observed_peptides: kobs,
609                theoretical_peptides: n,
610                coverage: if n > 0 { kobs as f64 / n as f64 } else { 0.0 },
611                binomial_score: score,
612                mean_rt_diff: 0.0,
613                mean_mass_z: 0.0,
614                mean_pep_score: 0.0,
615                q_value: q,
616            });
617            if t > 20 && q > (fdr * 25.0).max(0.5) {
618                break;
619            }
620            // Consume the winner's peptides (parsimony): remove them from every
621            // protein, decrement observed counts, and shrink the decoy pool so p
622            // drops for the next pick. Track which n-groups changed.
623            let won: Vec<usize> = prot_peps
624                .get(&key)
625                .map(|s| s.iter().copied().collect())
626                .unwrap_or_default();
627            let mut dirty_n: FxHashSet<usize> = FxHashSet::default();
628            for pi in won {
629                if let Some(keys) = pep_prots.get(&pi) {
630                    for k2 in keys.clone() {
631                        if let Some(s) = prot_peps.get_mut(&k2) {
632                            if s.remove(&pi) {
633                                if let Some(c) = obs.get_mut(&k2) {
634                                    *c -= 1;
635                                }
636                                if k2.0 {
637                                    decoy_obs_sum -= 1.0;
638                                }
639                                dirty_n.insert(theo_of(&k2));
640                            }
641                        }
642                    }
643                }
644            }
645            // The winner itself can never be picked again.
646            obs.insert(key, 0);
647            dirty_n.insert(n);
648            for dn in dirty_n {
649                match best_for_n(dn, &by_n, &obs) {
650                    Some(k) => {
651                        best_per_n.insert(dn, k);
652                    }
653                    None => {
654                        best_per_n.remove(&dn);
655                    }
656                }
657            }
658        }
659        proteins.sort_by(|a, b| {
660            b.binomial_score
661                .partial_cmp(&a.binomial_score)
662                .unwrap()
663                .then_with(|| a.accession.cmp(&b.accession))
664                .then(a.decoy.cmp(&b.decoy))
665        });
666        // FDR: running decoy/target ratio over the score-sorted list, made monotone.
667        let (mut t, mut d) = (0usize, 0usize);
668        for p in proteins.iter_mut() {
669            if p.decoy {
670                d += 1;
671            } else {
672                t += 1;
673            }
674            p.q_value = if t == 0 { 1.0 } else { d as f64 / t as f64 };
675        }
676        let mut run = f64::INFINITY;
677        for i in (0..proteins.len()).rev() {
678            run = run.min(proteins[i].q_value);
679            proteins[i].q_value = run.min(1.0);
680        }
681        return proteins;
682    }
683
684    let mut heap: BinaryHeap<GreedyEntry> = prot_peps
685        .iter()
686        .filter(|(_, peps)| !peps.is_empty())
687        .map(|(&key, peps)| GreedyEntry {
688            score: score_of(&key, peps.len()),
689            key,
690            n_obs: peps.len(),
691        })
692        .collect();
693    let mut proteins: Vec<ProteinId> = Vec::new();
694    let (mut t, mut d) = (0usize, 0usize);
695    loop {
696        let (key, obs, score) = loop {
697            let top = match heap.pop() {
698                Some(e) => e,
699                None => break (None, 0usize, 0.0f64),
700            };
701            match prot_peps.get(&top.key) {
702                Some(peps) if !peps.is_empty() && peps.len() == top.n_obs => {
703                    break (Some(top.key), top.n_obs, top.score);
704                }
705                _ => continue,
706            }
707        };
708        let key = match key {
709            Some(k) => k,
710            None => break,
711        };
712        let (decoy, acc) = key;
713        let theo = theoretical.get(&key).copied().unwrap_or(0) as usize;
714        if decoy {
715            d += 1;
716        } else {
717            t += 1;
718        }
719        let q = if t == 0 { 1.0 } else { d as f64 / t as f64 };
720        proteins.push(ProteinId {
721            accession: acc.to_string(),
722            decoy,
723            observed_peptides: obs,
724            theoretical_peptides: theo,
725            coverage: if theo > 0 {
726                obs as f64 / theo as f64
727            } else {
728                0.0
729            },
730            binomial_score: score,
731            mean_rt_diff: 0.0,
732            mean_mass_z: 0.0,
733            mean_pep_score: 0.0,
734            q_value: q,
735        });
736        if t > 20 && q > (fdr * 25.0).max(0.5) {
737            break;
738        }
739        let won: Vec<usize> = prot_peps
740            .get(&key)
741            .map(|s| s.iter().copied().collect())
742            .unwrap_or_default();
743        let mut affected: FxHashSet<(bool, &str)> = FxHashSet::default();
744        for pi in won {
745            if let Some(keys) = pep_prots.get(&pi) {
746                for k in keys.clone() {
747                    if let Some(s) = prot_peps.get_mut(&k) {
748                        s.remove(&pi);
749                        affected.insert(k);
750                    }
751                }
752            }
753        }
754        for k in affected {
755            if let Some(peps) = prot_peps.get(&k) {
756                if !peps.is_empty() {
757                    heap.push(GreedyEntry {
758                        score: score_of(&k, peps.len()),
759                        key: k,
760                        n_obs: peps.len(),
761                    });
762                }
763            }
764        }
765    }
766    proteins.sort_by(|a, b| {
767        b.binomial_score
768            .partial_cmp(&a.binomial_score)
769            .unwrap()
770            .then_with(|| a.accession.cmp(&b.accession))
771            .then(a.decoy.cmp(&b.decoy))
772    });
773    if permute {
774        // Decoy-free FDR: per-protein binomial p-value = 10^(-score); Benjamini-
775        // Hochberg over the TARGET proteins (sorted by score desc = p-value asc).
776        // Decoys are kept for diagnostics but get q=1 (they don't define the FDR).
777        let m = proteins.iter().filter(|p| !p.decoy).count().max(1) as f64;
778        let mut rank = 0usize;
779        for p in proteins.iter_mut() {
780            if p.decoy {
781                p.q_value = 1.0;
782                continue;
783            }
784            rank += 1;
785            let pv = 10f64.powf(-p.binomial_score);
786            p.q_value = (pv * m / rank as f64).min(1.0);
787        }
788        // Enforce monotone non-increasing q with rank (standard BH step-up).
789        let mut run = 1.0f64;
790        for p in proteins.iter_mut().rev() {
791            if p.decoy {
792                continue;
793            }
794            run = run.min(p.q_value);
795            p.q_value = run;
796        }
797        return proteins;
798    }
799    // Target/decoy pair deletion (uno scheme: same accession, opposite decoy flag).
800    let score_by: FxHashMap<(bool, String), f64> = proteins
801        .iter()
802        .map(|p| ((p.decoy, p.accession.clone()), p.binomial_score))
803        .collect();
804    proteins.retain(|p| match score_by.get(&(!p.decoy, p.accession.clone())) {
805        Some(&cs) => p.binomial_score > cs || (p.binomial_score == cs && !p.decoy),
806        None => true,
807    });
808    let (mut t, mut d) = (0usize, 0usize);
809    for p in proteins.iter_mut() {
810        if p.decoy {
811            d += 1;
812        } else {
813            t += 1;
814        }
815        p.q_value = if t == 0 { 1.0 } else { d as f64 / t as f64 };
816    }
817    let mut run = f64::INFINITY;
818    for i in (0..proteins.len()).rev() {
819        run = run.min(proteins[i].q_value);
820        proteins[i].q_value = run.min(1.0);
821    }
822    proteins
823}
824
825/// Faithful port of ms1searchpy's `worker`/`final_iteration` greedy protein
826/// inference: FEATURE-level competition (each MS1 peak supports exactly one
827/// protein) + a DYNAMICALLY re-estimated random-match probability `p`. `pep_feat`
828/// maps each candidate peptide to the MS1 feature (peak id) backing its best match.
829/// When a protein is accepted it claims those peaks; every other peptide sitting on
830/// a claimed peak dies, which shrinks the decoy pool so `p` drops and the next real
831/// protein scores higher — the feedback uno's peptide-level greedy can't produce.
832pub(crate) fn score_proteins_feature_compete(
833    observed: FxHashMap<(bool, &str), FxHashSet<usize>>,
834    pep_feat: &FxHashMap<usize, Vec<usize>>,
835    theoretical: &FxHashMap<(bool, &str), u32>,
836    decoy_theo: u64,
837    fdr: f64,
838) -> Vec<ProteinId> {
839    let theo_of = |key: &(bool, &str)| theoretical.get(key).copied().unwrap_or(0) as usize;
840    let mut prot_peps = observed;
841    // feature (peak id) -> peptides that matched it (a peptide can sit on several
842    // peaks: charge states / scans). A peptide dies only when ALL its peaks are
843    // claimed (ms1searchpy's banned_dict), so it survives competition the way ms1's
844    // multi-charge evidence does -- retaining ~7 peptides/protein where the old
845    // single-best-PFM mapping kept ~5.
846    let mut feat_peps: FxHashMap<usize, FxHashSet<usize>> = FxHashMap::default();
847    let mut all_peps: FxHashSet<usize> = FxHashSet::default();
848    for peps in prot_peps.values() {
849        for &pi in peps {
850            all_peps.insert(pi);
851        }
852    }
853    // Remaining (unbanned) peak count per peptide; peptide is dead at 0.
854    let mut rem_feat: FxHashMap<usize, usize> = FxHashMap::default();
855    for &pi in &all_peps {
856        if let Some(fs) = pep_feat.get(&pi) {
857            for &f in fs {
858                feat_peps.entry(f).or_default().insert(pi);
859            }
860            rem_feat.insert(pi, fs.len().max(1));
861        }
862    }
863    let mut pep_prots: FxHashMap<usize, Vec<(bool, &str)>> = FxHashMap::default();
864    for (&key, peps) in &prot_peps {
865        for &pi in peps {
866            pep_prots.entry(pi).or_default().push(key);
867        }
868    }
869    let mut obs: FxHashMap<(bool, &str), usize> =
870        prot_peps.iter().map(|(&k, v)| (k, v.len())).collect();
871    let decoy_theo_f = decoy_theo.max(1) as f64;
872    let mut decoy_obs_sum: f64 = obs
873        .iter()
874        .filter(|((d, _), _)| *d)
875        .map(|(_, &c)| c as f64)
876        .sum();
877
878    // Per-theoretical-count (bucket) selection of the max-observed protein.
879    //
880    // `obs[k]` only ever DECREASES over the greedy (peptides get removed as their
881    // peaks are claimed; the winner is zeroed). Exploit that monotonicity: keep,
882    // per bucket `n`, a lazy max-heap of `(obs_snapshot, Reverse(key))`. The head
883    // is the current best UNLESS its snapshot is stale (obs decremented since it
884    // was pushed); on read we pop stale/zeroed heads, re-pushing decremented ones
885    // at their current count, until the head's snapshot matches its live `obs`.
886    // Because `obs` is non-increasing and every live key holds exactly one heap
887    // entry with `snapshot >= live obs`, the first head with `snapshot == live`
888    // is the exact `max_by_key(obs, Reverse(key))` the old full-bucket rescan
889    // returned — byte-identical selection, but amortized O(log) per read instead
890    // of O(bucket_size). (The old rescan cost O(iters * bucket_size) ~ billions of
891    // scans; this bounds re-pushes by the total peptide-removal count.)
892    type BucketHeap<'a> = std::collections::BinaryHeap<(usize, std::cmp::Reverse<(bool, &'a str)>)>;
893    #[inline]
894    fn peek_best<'a>(
895        heap: &mut BucketHeap<'a>,
896        obs: &FxHashMap<(bool, &'a str), usize>,
897    ) -> Option<(usize, (bool, &'a str))> {
898        while let Some(&(snap, std::cmp::Reverse(key))) = heap.peek() {
899            let cur = obs.get(&key).copied().unwrap_or(0);
900            if cur == 0 {
901                heap.pop();
902                continue;
903            }
904            if cur < snap {
905                heap.pop();
906                heap.push((cur, std::cmp::Reverse(key)));
907                continue;
908            }
909            return Some((cur, key));
910        }
911        None
912    }
913    let mut heaps: FxHashMap<usize, BucketHeap> = FxHashMap::default();
914    for (&k, &c) in &obs {
915        let n = theo_of(&k);
916        if n > 0 && c > 0 {
917            heaps.entry(n).or_default().push((c, std::cmp::Reverse(k)));
918        }
919    }
920
921    let mut proteins: Vec<ProteinId> = Vec::new();
922    let (mut t, mut d) = (0usize, 0usize);
923    let mut dead: FxHashSet<usize> = FxHashSet::default();
924    let mut banned_feat: FxHashSet<usize> = FxHashSet::default();
925    let mut empty_buckets: Vec<usize> = Vec::new();
926    loop {
927        let p = (decoy_obs_sum / decoy_theo_f).clamp(1e-9, 0.999);
928        let mut best: Option<((bool, &str), f64, usize, usize)> = None;
929        empty_buckets.clear();
930        for (&n, heap) in heaps.iter_mut() {
931            let (kobs, k) = match peek_best(heap, &obs) {
932                Some(x) => x,
933                None => {
934                    empty_buckets.push(n);
935                    continue;
936                }
937            };
938            let s = -binom_sf_ge_k(kobs as u32, n as u32, p).log10();
939            let better = match best {
940                None => true,
941                Some((bk, bs, _, _)) => s > bs || (s == bs && k < bk),
942            };
943            if better {
944                best = Some((k, s, kobs, n));
945            }
946        }
947        for n in empty_buckets.drain(..) {
948            heaps.remove(&n);
949        }
950        let (key, score, kobs, n) = match best {
951            Some(x) => x,
952            None => break,
953        };
954        let (decoy, acc) = key;
955        if decoy {
956            d += 1;
957        } else {
958            t += 1;
959        }
960        let q = if t == 0 { 1.0 } else { d as f64 / t as f64 };
961        proteins.push(ProteinId {
962            accession: acc.to_string(),
963            decoy,
964            observed_peptides: kobs,
965            theoretical_peptides: n,
966            coverage: if n > 0 { kobs as f64 / n as f64 } else { 0.0 },
967            binomial_score: score,
968            mean_rt_diff: 0.0,
969            mean_mass_z: 0.0,
970            mean_pep_score: 0.0,
971            q_value: q,
972        });
973        if t > 20 && q > (fdr * 25.0).max(0.5) {
974            break;
975        }
976        // The winner claims the peaks of all its peptides; every peptide on a
977        // claimed peak dies (removed from every protein), shrinking the pool.
978        let won_peps: Vec<usize> = prot_peps
979            .get(&key)
980            .map(|s| s.iter().copied().collect())
981            .unwrap_or_default();
982        // The winner claims every peak under its peptides. Banning a peak decrements
983        // each peptide on it; a peptide dies only when its LAST peak is claimed.
984        let mut to_kill: Vec<usize> = Vec::new();
985        for pi in &won_peps {
986            match pep_feat.get(pi) {
987                Some(fs) if !fs.is_empty() => {
988                    for &f in fs {
989                        if banned_feat.insert(f) {
990                            if let Some(ps) = feat_peps.get(&f) {
991                                for &pj in ps {
992                                    if let Some(r) = rem_feat.get_mut(&pj) {
993                                        if *r > 0 {
994                                            *r -= 1;
995                                            if *r == 0 {
996                                                to_kill.push(pj);
997                                            }
998                                        }
999                                    }
1000                                }
1001                            }
1002                        }
1003                    }
1004                }
1005                _ => to_kill.push(*pi),
1006            }
1007        }
1008        // Removing peptides only DECREMENTS `obs`; the per-bucket lazy heaps pick up
1009        // the new counts on their next read (no eager bucket recompute needed).
1010        for pj in to_kill {
1011            if !dead.insert(pj) {
1012                continue;
1013            }
1014            if let Some(keys) = pep_prots.get(&pj) {
1015                for k2 in keys.clone() {
1016                    if let Some(s) = prot_peps.get_mut(&k2) {
1017                        if s.remove(&pj) {
1018                            if let Some(c) = obs.get_mut(&k2) {
1019                                *c -= 1;
1020                            }
1021                            if k2.0 {
1022                                decoy_obs_sum -= 1.0;
1023                            }
1024                        }
1025                    }
1026                }
1027            }
1028        }
1029        // Winner can never be picked again (its peaks are all consumed).
1030        obs.insert(key, 0);
1031        if let Some(s) = prot_peps.get_mut(&key) {
1032            s.clear();
1033        }
1034    }
1035    proteins.sort_by(|a, b| {
1036        b.binomial_score
1037            .partial_cmp(&a.binomial_score)
1038            .unwrap()
1039            .then_with(|| a.accession.cmp(&b.accession))
1040            .then(a.decoy.cmp(&b.decoy))
1041    });
1042    // ms1searchpy's protein FDR (final_iteration:401-413): (1) target/decoy PAIR
1043    // competition -- for each accession keep only the better-scoring of X / DECOY_X;
1044    // (2) FDR with the conservative +1 decoy correction: q = (decoys+1)/targets
1045    // (pyteomics aux.filter correction=1). My first cut used a bare decoys/targets
1046    // with no pair deletion, which ran ~1.5x optimistic.
1047    let score_by: FxHashMap<(bool, String), f64> = proteins
1048        .iter()
1049        .map(|p| ((p.decoy, p.accession.clone()), p.binomial_score))
1050        .collect();
1051    proteins.retain(|p| match score_by.get(&(!p.decoy, p.accession.clone())) {
1052        Some(&cs) => p.binomial_score > cs || (p.binomial_score == cs && !p.decoy),
1053        None => true,
1054    });
1055    // Target/decoy ratio after pair competition, with ms1searchpy's conservative +1
1056    // phantom-decoy correction (pyteomics correction=1) ON by default. Dropping it
1057    // (UNO_NO_FDR_PLUS1=1) raised the reported count slightly but did NOT improve
1058    // honest entrapment depth -- it just reports nearer the (still-conservative)
1059    // decoy-estimated FDR. Keep the honest default.
1060    let plus1 = if std::env::var("UNO_NO_FDR_PLUS1").is_ok() {
1061        0.0
1062    } else {
1063        1.0
1064    };
1065    let (mut t, mut d) = (0usize, 0usize);
1066    for p in proteins.iter_mut() {
1067        if p.decoy {
1068            d += 1;
1069        } else {
1070            t += 1;
1071        }
1072        p.q_value = if t == 0 {
1073            1.0
1074        } else {
1075            (d as f64 + plus1) / t as f64
1076        };
1077    }
1078    let mut run = f64::INFINITY;
1079    for i in (0..proteins.len()).rev() {
1080        run = run.min(proteins[i].q_value);
1081        proteins[i].q_value = run.min(1.0);
1082    }
1083    proteins
1084}
1085
1086#[cfg(test)]
1087mod joint_alternative_tests {
1088    use super::*;
1089    #[test]
1090    fn alternative_peak_preserves_one_peptide_after_primary_peak_is_claimed() {
1091        let observed: FxHashMap<(bool, &str), FxHashSet<usize>> = [
1092            ((false, "A"), [0, 1, 2].into_iter().collect()),
1093            ((false, "B"), [3, 4].into_iter().collect()),
1094            ((true, "D"), [5, 6].into_iter().collect()),
1095        ]
1096        .into_iter()
1097        .collect();
1098        let theo = [((false, "A"), 10), ((false, "B"), 20), ((true, "D"), 1000)]
1099            .into_iter()
1100            .collect();
1101        let mut peaks: FxHashMap<usize, Vec<usize>> = [
1102            (0, vec![0]),
1103            (1, vec![1]),
1104            (2, vec![2]),
1105            (3, vec![0]),
1106            (4, vec![4]),
1107            (5, vec![5]),
1108            (6, vec![6]),
1109        ]
1110        .into_iter()
1111        .collect();
1112        let single = score_proteins_feature_compete(observed.clone(), &peaks, &theo, 1000, 0.01);
1113        peaks.insert(3, vec![0, 7, 8]);
1114        let multiple = score_proteins_feature_compete(observed, &peaks, &theo, 1000, 0.01);
1115        assert_eq!(single[0].accession, "A");
1116        assert_eq!(multiple[0].accession, "A");
1117        assert_eq!(
1118            single
1119                .iter()
1120                .find(|p| p.accession == "B")
1121                .unwrap()
1122                .observed_peptides,
1123            1
1124        );
1125        // Two alternate peaks preserve ONE peptide, not two extra observations.
1126        assert_eq!(
1127            multiple
1128                .iter()
1129                .find(|p| p.accession == "B")
1130                .unwrap()
1131                .observed_peptides,
1132            2
1133        );
1134    }
1135}