Skip to main content

uno_core/
score.rs

1//! Phase 5: discriminant scoring, bootstrap RT calibration, and target-decoy FDR.
2//!
3//! Pipeline:
4//!   1. Reduce the (huge) PFM set to the top-K best-mass-error target and decoy
5//!      matches per feature. MS1 mass-matching yields ~hundreds of candidates
6//!      per feature; all but a few are hopeless, and keeping every one makes the
7//!      discriminant intractable. Top-K preserves the RT-correct peptide while
8//!      cutting the set ~100x.
9//!   2. Bootstrap RT calibration: score once without RT, accept a provisional
10//!      target set at low FDR, fit abstract->observed RT calibration on it.
11//!   3. Re-score with the `rt_diff` feature active, recalibrating each round.
12//!   4. A 3-fold CV gbdt discriminant (split BY FEATURE so a feature's PFMs are
13//!      never used to train the model that scores them) avoids overfitting to
14//!      decoys. Target-decoy competition per feature -> q-values.
15
16use 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
25/// Order-invariant "which feature best represents a peptide" comparator, BEST
26/// first. When one peptide matches several features (or koth's exhaustive mode
27/// emits near-duplicates that tie on mass error / deviation), the representative
28/// must be chosen by feature *quality*, not by whichever row happened to be seen
29/// first — otherwise the result depends on the input's feature row order. Higher
30/// `combined_score`, then higher apex intensity, then more isotopes; ties broken
31/// by ascending mass/rt/mz so the order is total and reproducible.
32fn feature_cmp(a: &Feature, b: &Feature) -> Ordering {
33    // Total intensity (sum over runs); for single-run input this is just the one
34    // value, for an aligned matrix it's the cross-run total — a stable summary in
35    // both cases (unlike `.first()`, which is run 0 and may be an empty cell).
36    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    /// Joint-input ablation: use candidate-specific observations but disable
57    /// cross-run agreement descriptors in the discriminant.
58    pub matrix_joint_agreement: bool,
59    /// Experimental row-level MBR/extraction-q descriptors in joint searches.
60    pub matrix_joint_cell_quality: bool,
61    /// Keep fill-count descriptors when cell-quality scoring is enabled.
62    pub matrix_joint_cell_counts: bool,
63    /// Keep q-derived descriptors when cell-quality scoring is enabled.
64    pub matrix_joint_cell_q: bool,
65    /// Let coherent alternate matrix rows preserve a peptide during competition.
66    pub matrix_joint_alternative_groups: bool,
67    /// Optional diagnostic export from the selected calibration only.
68    pub diagnostic_peptides_path: Option<String>,
69    /// Ablate only run-recurrence classifier columns; preserve quantities and rows.
70    pub matrix_recurrence: bool,
71    /// Keep this many best-mass-error targets and decoys per feature. This caps
72    /// the candidate set the discriminant sees, so setting it too low silently
73    /// drops real peptides before RT can rescue them. Entrapment (human+yeast on
74    /// single-run HeLa) showed 25 identified only ~half of what the search
75    /// honestly supports: 25→105 IDs, 50→208, 100→275, all at ~1% entrapment
76    /// FDP. 50 is the default (≈2× IDs over 25 at negligible cost); raise toward
77    /// 100 for maximum depth at a runtime cost.
78    pub top_k: usize,
79    pub n_folds: usize,
80    /// FDR used to select the provisional set for RT calibration.
81    pub calibration_fdr: f64,
82    /// Number of re-score rounds after the first calibration.
83    pub bootstrap_rounds: usize,
84    pub iterations: usize,
85    pub max_depth: u32,
86    pub shrinkage: f32,
87    /// Cap on training rows per fold-model (0 = no cap). Bounds training time.
88    pub max_train_rows: usize,
89    /// Seed features must appear in at least this fraction of samples. Lower for
90    /// many-sample designs (e.g. dose-response) where a peptide need not be in
91    /// every run; the RANSAC seed tolerates the extra contamination.
92    pub seed_min_runs_frac: f64,
93    /// Fallback seed for scoring's random components. 0 = the shipped default
94    /// streams (see [`crate::seed_mix`]). `uno --seed` sets this value; explicit
95    /// `rt_seed`, `cv_seed`, or `classifier_seed` values take precedence.
96    pub seed: u64,
97    /// Override only RANSAC RT-calibration pair sampling. None uses `seed`.
98    pub rt_seed: Option<u64>,
99    /// Override only cross-validation fold assignment. None uses `seed`.
100    pub cv_seed: Option<u64>,
101    /// Override classifier row/column sampling and LightGBM hyperparameter
102    /// search. Applies to both Sequoia and LightGBM. None uses `seed`.
103    pub classifier_seed: Option<u64>,
104    /// Mass tolerance (ppm) the mass-anchor bootstrap starts at, and the ceiling it
105    /// tightens toward as it recalibrates the per-run mass shift + sigma. Default 10
106    /// (Orbitrap). For lower-accuracy instruments (e.g. timsTOF) set this wider —
107    /// e.g. 20 — plus `[matching] ppm_tolerance = 20`; the anchor then seeds wide and
108    /// tightens to single-digit ppm once the mass error is recalibrated on-data.
109    pub mass_anchor_ppm: f64,
110    /// Discriminant: "deviation" (robust orthogonal RT+IM consistency, ms1searchpy
111    /// style — default) or "gbdt" (semi-supervised boosted trees). On the
112    /// MS1-only/saturated-mass regime the gbdt washes out the weak RT+IM signal,
113    /// so the deviation score is the default.
114    pub discriminant: String,
115    /// Refit the RT predictor on *this run's* observed RTs (ridge on composition
116    /// features) using the confident set, each iteration. The DirectMS1 trick:
117    /// the frozen DeepLC model only rank-correlates ~0.3 on an unseen gradient,
118    /// but a model fit to the data makes RT a strong discriminator. Default on.
119    pub ondata_rt: bool,
120    /// Ridge penalty for the on-data RT fit.
121    pub ondata_rt_lambda: f64,
122    /// Fine-tune mode for the on-data RT fit (requires `ondata_rt` and an MLP RT
123    /// model). Instead of fitting a fresh ridge on raw composition features (a
124    /// weak linear model that discards the pretrained representation), refit the
125    /// readout over the MLP's frozen penultimate embedding — keeping its learned
126    /// nonlinear RT structure while adapting offset/scale/shape to this run.
127    pub ondata_rt_finetune: bool,
128    /// Bootstrap the RT calibration from MASS-ONLY protein identifications
129    /// (ms1searchpy stage-1) instead of the per-peptide RANSAC seed. On dense
130    /// single-run data the per-peptide seed is ~99% contaminated; confident
131    /// proteins (mass multiplicity) give far cleaner RT anchors.
132    pub mass_anchor: bool,
133    /// The RT library is already calibrated to this run's RT (e.g. DeepLC
134    /// `calibrate_preds`). uno then uses rt_diff = obs - pred directly and does
135    /// NOT apply its own calibration on top (which would double-calibrate and
136    /// distort the predictions).
137    pub rt_calibrated: bool,
138    /// CV-gbdt PFM peptide-quality ranking, fed through a 10-step cumulative
139    /// qpred-bin ensemble protein score (ms1searchpy `-ml 1` + `final_iteration`).
140    /// VALIDATED (2026-07-07, PXD015669 human+yeast entrapment, native biosaur2
141    /// features with isoerror/isoerror2/rt_width): 223 real/4 yeast (deviation
142    /// alone) -> 251 real/1 yeast with this on — deeper AND more honest. The
143    /// per-PFM classifier itself still barely separates decoys from dim-real
144    /// peptides on uno's pure-Rust gbdt (dim_mean ~= DECOY_mean); the gain comes
145    /// from the ENSEMBLE STRUCTURE (10 cumulative thresholds, averaged, like
146    /// ms1searchpy's `final_iteration`), not classifier quality. When a feature
147    /// file lacks the quality columns it correctly no-ops (guarded by a strict
148    /// better-than-baseline check).
149    ///
150    /// ON by default (see `ScoreConfig::default`), and active in every benchmark
151    /// reported in the manuscript. It costs a re-run of the protein engine per
152    /// inclusion band, so it trades latency for depth. Set false for the plain
153    /// deviation path.
154    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            // On by default IN FINE-TUNE MODE: refit only the MLP readout over its
183            // frozen embedding on this run's confident hits. This keeps the
184            // pretrained representation (unlike the from-scratch composition ridge,
185            // which degrades depth) and is FDR-honest under two-proteome entrapment
186            // (0 entrapment false positives, same as the frozen path). Falls back
187            // to frozen RT automatically when the model has no embedding (gbdt).
188            ondata_rt: true,
189            ondata_rt_lambda: 1.0,
190            ondata_rt_finetune: true,
191            mass_anchor: true,
192            rt_calibrated: false,
193            // ms1searchpy's `-ml 1` CV-boosted PFM rescorer + 10-step protein
194            // ensemble. ON by default: run10 honest yeast-entrapment 412->610 @1%
195            // (deterministic; +90% to 781 once the koth cosine_score/isotope_score
196            // reach the ML — see run10 notes). Auto-no-regression guarded
197            // (`ml_n > best_n` before it swaps in), so it can only deepen the
198            // deviation result. Reproducible only with the LightGBM seed pinning +
199            // `items` sort below.
200            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/// Result of scoring: the confident protein set from the mass-anchor mass+RT
220/// binomial identifier (ms1searchpy-style). This IS uno's deliverable.
221#[derive(Debug, Clone)]
222pub struct ScoreResult {
223    pub proteins: Vec<crate::protein::ProteinId>,
224    /// RT+mass-gated best feature per peptide (target and decoy), for the
225    /// multi-sample quant rollup. Empty when the ML rescore stage did not run.
226    pub peptides: Vec<PeptideHit>,
227}
228
229/// Per-PFM feature vector for the discriminant. `rt_diff`/`im_diff` are `None`
230/// before their calibration is available, in which case those columns carry no
231/// signal (ion mobility is absent entirely on non-IM data).
232fn 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        // Per-feature shape (biosaur2 single-run inputs): a well-formed feature
257        // (more isotopic peaks, more scans) is more trustworthy than a junk
258        // feature, which contributes random target+decoy matches equally. 0 when
259        // the feature file lacks the column, so this is inert there.
260        (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        // Single-run PFM-quality columns (present on biosaur2 feature files).
263        // These are the exact signals ms1searchpy's LightGBM PFM filter uses to
264        // drop the random-match rate p to ~0.135 (its @5% depth). 0.0 when absent
265        // (aligned matrices), so inert there — active only on single-run inputs.
266        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
277/// Stable fold assignment for a feature index.
278fn 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
299/// Per-peptide posterior P(correct | dev) from the target vs decoy `dev`
300/// distributions, for the continuous (sweep-free) protein scorer. Decoys are the
301/// null; at a given `dev` the fraction of targets in excess of decoys is the correct
302/// fraction: w = clamp(1 - scale * n_decoy / n_target, 0, 1), estimated in
303/// equal-count `dev` bins, then made non-increasing in `dev` by pool-adjacent-
304/// violators (larger deviation can only be *less* likely correct). `scale` corrects
305/// for an unequal target:decoy DB size (~1.0 for uno's 1:1 decoys).
306fn 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    // Isotonic (non-increasing) via pool-adjacent-violators: merge a bin into its
341    // predecessor whenever the predecessor is SMALLER (an increase = a violation).
342    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
372/// Final scoring output and the peptide evidence from the same calibration
373/// candidate. Keep these owned together until candidate selection is complete.
374struct FinalizedCandidate {
375    proteins: Vec<ProteinId>,
376    n_confident: usize,
377    peptides: Vec<(u32, u32, bool)>,
378    // pi, feature, decoy, ML score, deviation, rank fraction, included in any pass
379    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
391/// Preserve scored evidence even when no cut passes the reporting threshold.
392/// Only all-zero 1% counts use the existing 25%-FDR ML training set as a tie
393/// breaker. A nonzero count always wins; equal nonzero counts keep the first cut.
394fn 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        // Evidence retained for training must not displace actual discoveries.
437        // Before zero-discovery cuts retained their lists, their soft depth was
438        // zero, so a positive-discovery candidate already always outranked them.
439        let rank = (protect_discoveries && candidate.n_confident > 0, score);
440        // Strict comparison deliberately preserves the first candidate on ties.
441        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
449/// Recovery is a fallback after the complete original search, never an extra
450/// competitor against a successful ranking. Keep the original evidence if the
451/// retry also fails to produce any reported target discoveries.
452fn 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
471/// Run complete V3 first, then at most one broad pass. Timing is diagnostic
472/// only and never participates in candidate acceptance or ranking.
473fn 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
509/// Mass-only clean-anchor RT bootstrap (ms1searchpy's stage-1 trick).
510///
511/// On dense single-run data the per-peptide RANSAC seed is ~99% contaminated, so
512/// RT calibration is fit on garbage. Instead, identify confident PROTEINS by MASS
513/// ALONE (multiplicity: real proteins accumulate several tight-mass peptides while
514/// decoy proteins stay singletons), then take *their* peptides as RT-calibration
515/// anchors — a far cleaner set, because a present protein's tight-mass peptides are
516/// enriched for genuinely-present peptides. Returns (calibration, anchor positions,
517/// mass_shift_ppm, mass_sigma_ppm).
518fn 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    // Best mono (offset==0) match per peptide: (mass_error_ppm, reduced position).
531    // A peptide is "matched" if this best error falls inside the current mass
532    // window — binary set membership, exactly like ms1searchpy's `set(seqs)`.
533    // Restrict to high-isotope features (ms1searchpy's `ci`=4 calibration quality)
534    // when shape descriptors are present. Junk low-isotope features contribute most
535    // of the random mass-coincidence background; excluding them drops the binomial's
536    // p from ~0.44 toward ms1searchpy's ~0.10, which is the bulk of the mass-stage
537    // depth gap. Inert (no filter) when the input lacks n_isotopes.
538    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            // Exact mass-error tie (koth's exhaustive mode emits near-duplicate
568            // features at the same mass). Break it on feature quality so the
569            // representative feature — and thus this peptide's anchor RT — does not
570            // depend on feature row order. Without this, `best` picks the first-seen
571            // feature, making RANSAC calibration and the whole result swing with the
572            // input's row order.
573            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    // ALL mono PFMs per peptide (every feature it matches), no isotope filter —
587    // used by the RT stage. A present peptide is "matched" if ANY of its features
588    // is consistent in BOTH mass and RT (ms1searchpy's set membership over all
589    // PFMs). Using only the best-MASS feature misses present peptides whose real
590    // feature is out-massed by a random coincidence; checking every feature finds
591    // the real one (tight mass AND tight RT) — this is the 7x RT multiplier.
592    // ALL mono PFMs per peptide (every feature it matches), for the single-pass
593    // any-feature RT stage: a peptide is "seen" if ANY of its features is consistent
594    // in both mass and RT (recovers low-isotope real peptides -> the RT multiplier).
595    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    // Canonicalize each peptide's PFM list best-feature-first (see `feature_cmp`),
606    // so every downstream "best PFM" selection with a first-seen / strict-`<`
607    // tie-break resolves identically regardless of the input's feature row order.
608    // Without this, koth's exhaustive near-duplicate features (equal mass_error or
609    // equal mass+RT deviation) let row order pick a different representative PFM,
610    // perturbing the ML feature vectors and swinging the final protein count.
611    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    // Theoretical peptide count per (decoy, accession), and the decoy totals used
619    // for the binomial background rate p = mean(decoy matched)/mean(decoy theo).
620    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    // Iterative bootstrap: start at the full search tolerance, identify confident
633    // proteins by the size-adjusted binomial, recalibrate mass on THEIR peptides,
634    // tighten the window to +/-3 sigma, and repeat. The discrimination comes from
635    // the binomial detecting each real protein's ~n_present excess of matched
636    // peptides over the p*n random background — it works at high p too.
637    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    // The tol=3*sigma feedback loop is not guaranteed to converge monotonically: an
642    // early round admitting a few extra random matches can inflate that round's
643    // recalibrated sigma, which widens next round's tolerance, admitting MORE noise,
644    // in a runaway spiral. Measured repeatedly (run10, multiple configs): round 1
645    // finds the MOST confident proteins with the tightest sigma, then rounds 2-4
646    // monotonically WORSEN (e.g. 174->85->87->70 confident, sigma 0.64->0.74) even
647    // though the loop keeps running and unconditionally uses the LAST round's
648    // result. Track the best round by confident-protein count and use IT, instead
649    // of assuming the final round is the best one.
650    #[allow(clippy::type_complexity)]
651    let mut best_round: Option<(usize, f64, f64, Vec<usize>, f64, HashSet<(bool, &str)>)> = None;
652    // Both calibrations below (mass shift/sigma, RANSAC RT) need at least this
653    // many anchor peptides; a bootstrap ending under it produces no calibration.
654    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        // Matched peptides -> per-protein positions.
665        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        // Size-adjusted binomial score per protein.
689        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        // Target/decoy FDR walk -> confident target proteins (q <= 0.05).
699        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        // Anchors + recalibration set = matched peptides of confident proteins.
716        // Sorted key order: `best` is a HashMap, and `cerr` is reduced by
717        // `clipped_sigma` below, which is a float reduction.
718        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                // DECOY GUARD. `confident` is keyed (is_decoy, accession) and this
730                // asks only for the TARGET entry, but a decoy peptide carries the
731                // same parent accessions as the target it was shuffled from, so
732                // without this check a decoy peptide of a confident target protein
733                // is admitted as a mass anchor and into the recalibration sample.
734                // The RT path (`est_rt_sig`) has always guarded this; the mass path
735                // did not, which is the asymmetry that gave it away.
736                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        // ANCHOR-STARVATION RESCUE (candidate only; applied after the loop). The
752        // walk above uses the INSTANTANEOUS d/t, so the confident set is
753        // essentially "the targets ranked above the first decoy protein" (after
754        // the first decoy, d/t stays > 0.05 until t >= 20d). Where the first
755        // decoy lands is a property of the decoy draw, not of the data: on timsTOF
756        // Ultra 2 at p~0.22 the same run gives 21 targets above the first decoy
757        // with one seed and 7 with another. 7 proteins carry ~35 anchor peptides,
758        // below the 50 both calibrations need; when no round does better, mass
759        // stays at the 3.5 ppm default, RT calibration is skipped, and the run
760        // reports 0 proteins (5 of 20 seeds, 2026-09-11). A proper (suffix-min)
761        // q-value does not help: at p~0.22 the mass-only binomial separates only
762        // ~20 proteins from the decoys and below that the two interleave, so a
763        // q-controlled set is intrinsically tiny and seed-dependent. The anchors
764        // are a CALIBRATION sample, not a result: they feed median / clipped-sigma
765        // / RANSAC, all of which tolerate contamination (a healthy seed's 258
766        // anchors were themselves 59% RANSAC outliers). So take the top-scored
767        // target proteins regardless of q until the sample is big enough — but
768        // only as a fallback for a bootstrap that ends uncalibrated. Applying it
769        // to any starved ROUND is too eager: seeds whose round 0 starves but whose
770        // later rounds recover on their own lose depth to the contaminated set
771        // (302->124, 301->200, 277->137, 221->170), while the post-loop fallback
772        // leaves every run that calibrates at all byte-identical.
773        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            // Unique matched positions, so shared peptides are not double-counted.
779            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        // Recalibrate mass shift + Gaussian-core sigma on the confident peptides.
810        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    // Experimental broad evidence ignores target/decoy and protein labels. The
868    // mass bootstrap is unchanged; only its shift/gate defines this larger pool.
869    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; // Bounded candidate: diagnostic ambiguity floor is not applied.
896    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        // 6. RANSAC RT calibration on the (abstract, observed) pairs of the anchors.
904        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        // keep only the RANSAC-inlier anchors (cleanest)
948        let clean: Vec<usize> = inl
949            .iter()
950            .map(|&k| {
951                if broad_rt_bootstrap {
952                    pair_positions[k]
953                } else {
954                    // Preserve V3 bookkeeping as well as its ranking on the first path.
955                    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    // === Stage 2 (SINGLE deterministic pass; ms1searchpy-faithful) ===
978    // The RT calibration `cal` is now FIXED (fit on the clean mass-stage anchors).
979    // The previous iterative recalibration loop oscillated (calibration <-> confident
980    // set feedback) and made the result DB-composition-dependent. Here we estimate
981    // RT_sigma ONCE and do ONE any-feature mass+RT binomial pass -> deterministic
982    // and stable. A peptide is "seen" if ANY of its features is consistent in both
983    // mass and RT (the low-isotope-recovery that gives the RT multiplier).
984    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    // RT accuracy from confident-protein peptides' best-RT (real-feature) residuals,
1007    // Gaussian core, clamped to weight RT ~its true accuracy in the deviation.
1008    // Floor applied to every RT-sigma estimate after a stage-1 collapse (see the
1009    // low-input note in the stage loop): an estimate from <25 proteins is not
1010    // trustworthy and the deviation search under-gates. 0.0 = no floor.
1011    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    // Multiplier on every RT-sigma estimate (UNO_SMOOTH candidate runs). 1.0 = off.
1014    let rt_sig_mult: std::cell::Cell<f64> = std::cell::Cell::new(1.0);
1015    // UNO_SMOOTH: choose between calibration candidates / stages / ML by a soft
1016    // depth (sum over targets of clamp(1 - q/0.05, 0, 1)) instead of the count at
1017    // q<=1%, which is a step function on short lists (one decoy needs 100 targets).
1018    // Calibration-candidate selection (default level 2, shipped 2026-09-04).
1019    // The stage loop and the ML stage each pick winners by the count of proteins
1020    // at q<=1%, which is a step function on short lists (one decoy needs 100
1021    // targets above it), so at low input two near-identical runs can land at
1022    // 187 and 866 proteins. Level 2 runs the unchanged pipeline at two RT sigmas
1023    // (x1.0 and x1.5) and keeps the candidate whose FINAL list has the larger
1024    // soft depth, sum over targets of clamp(1 - q/0.05, 0, 1). Validated on the
1025    // 14-file PXD066701 input series + 6 primary files: 1 ng rep4 187 -> 768,
1026    // 1 ng rep2 688 -> 925 at 1% entrapment FDR, 16 of 20 files byte-identical,
1027    // wall time +50-80% (the ML stage runs twice).
1028    // UNO_SMOOTH=0 restores the single-candidate path; 1 = soft objective also
1029    // inside the stage loop and ML take (misaligned with entrapment depth,
1030    // 10 ng rep1 1448 -> 1200, do not use); 3 = candidates chosen by the final
1031    // internal q<=1% count (worse: internal FDR is a poor proxy, 100 ng rep3 -298).
1032    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(); // best rt_diff per peptide (uno legacy)
1041            let mut all_rd: Vec<f64> = Vec::new(); // ALL confident-peptide PFM rt_diffs (ms1's noisygaus input)
1042                                                   // Sorted key order, not `&pep_pfms` directly: HashMap iteration is
1043                                                   // process-random, and while the resulting SET of residuals is the same
1044                                                   // either way, the reducers below are float reductions whose result
1045                                                   // depends on the order they see. Deterministic here, and `clipped_sigma`
1046                                                   // sorts defensively as well.
1047            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            // ms1searchpy's `calibrate_RT_gaus`: fit a Gaussian+baseline (noisygaus) to the
1093            // rt_diff histogram of the CONFIDENT peptides' best matches (ms1's df1[top_peps]),
1094            // take the peak sigma = the REAL per-peptide RT accuracy. A proper Gaussian fit
1095            // (vs `clipped_sigma`'s 3.5-clamped whole-spread or the HWHM's over-tight peak).
1096            // NOTE: faithfully porting ms1's noisygaus here gives rt_sig~2.15 and 791 @1%
1097            // (WORSE than clipped's 904) — ms1's tight gate is calibrated for DeepLC's ~1min
1098            // RT accuracy; uno's CNN RT is ~3min so the tight gate rejects real peptides.
1099            // The algorithm is faithful; uno's RT MODEL is the limiting input. Default =
1100            // clipped (works best with the CNN). UNO_RT_NOISYGAUS for the faithful ms1 fit.
1101            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            // A rescued bootstrap set is contaminated by construction, and clipped_sigma
1107            // is biased up by a flat background it cannot clip away (timsTOF Ultra 2,
1108            // seed 7: clipped 1.02 min on the rescued 20 proteins vs 0.50 on a healthy
1109            // seed's 28, while the Gaussian-core peak reads 0.38 on both; with 1.02 the
1110            // deviation stage finds nothing). Read the peak instead on that path only.
1111            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    // Per-stage MASS recalibration on the confident set (ms1searchpy's biggest p
1124    // drop is mass recal: p 0.68->0.30 at stage 1, then RT/ML tighten to 0.136;
1125    // uno's bootstrap tightened mass ONCE then froze it, leaving p~0.44). Each stage,
1126    // refit the mass shift + Gaussian-core sigma on the CURRENT confident target
1127    // peptides' best RT-consistent match, and tighten the gate to 3*sigma (NEVER
1128    // widen -- monotone, so coincidental matches fall outside the shrinking window
1129    // while real ones stay in). Returns (shift, sigma, gate). Gated by UNO_STAGE_MASS
1130    // (default on); when off, stages reuse the frozen bootstrap mass (old behavior).
1131    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            // mass error of this peptide's best RT-consistent match (min |rt_diff|
1144            // within the current mass gate) -- the same match the deviation pass keys on.
1145            let mut best: Option<(f64, f64)> = None; // (|rt_diff|, mass_err)
1146            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        // never widen the gate: monotone tightening only.
1166        let gate = (3.0 * sigma).min(cur_gate);
1167        (shift, sigma, gate)
1168    };
1169    // Default OFF: measured negative (per-stage recal RE-ESTIMATES sigma LOOSER than
1170    // the bootstrap's tightest-round subset -- 0.63->0.91 ppm -- so it widens, not
1171    // tightens; CNN 904->705, DeepLC 723->687). The bootstrap already tightens mass
1172    // optimally; mass is not uno's p bottleneck. Kept env-gated for future work.
1173    let stage_mass = std::env::var("UNO_STAGE_MASS")
1174        .map(|v| v == "1")
1175        .unwrap_or(false);
1176
1177    // One deviation pass (given a calibration + rt_sig): per peptide, ANY-FEATURE
1178    // match by MINIMUM COMBINED deviation dev = mass_z^2 + (rt_diff/rt_sig)^2 (finds
1179    // the real feature, tight in BOTH dims; rejects coincidences tight in one). Scan
1180    // the cutoff, protein binomial, target/decoy FDR. Returns the best result and
1181    // the confident (q<=1%) target accessions (for two-stage recalibration).
1182    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        // Per peptide, ALL (dev, feature) pairs across its PFMs (charge states /
1190        // scans), so feature-competition can keep a peptide alive until EVERY one of
1191        // its peaks is claimed (ms1searchpy's banned_dict), not just its single best.
1192        // Feature-quality gate (koth combined_score / cosine etc.): exclude peptides
1193        // backed by low-quality (junk) features, which contribute disproportionately
1194        // many coincidental decoy/wrong matches. biosaur's combined_score is const
1195        // 1.0 so this is inert there; koth's is a real isotope-fit quality. Env knob.
1196        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        // `pep_all_feats` (the per-PFM (dev, feature) list) is ONLY read by the
1201        // UNO_MULTI_FEAT cut-sweep branch; when that's off (default) building it is
1202        // pure waste (a Vec alloc + 17M pushes + a map insert per stage). Read the
1203        // flag up front and skip the work entirely in the common case.
1204        let want_feats = std::env::var("UNO_MULTI_FEAT").is_ok();
1205        // Per-peptide min-combined-deviation match over all its PFMs. Independent per
1206        // peptide -> parallel map, deterministic collect (results reinserted into the
1207        // maps; key order is irrelevant to correctness).
1208        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                // Every PFM of a peptide shares the same abstract (predicted) RT, so the
1215                // calibration interpolation is identical across the list -- compute it
1216                // ONCE per peptide instead of per PFM (17M `cal.apply` -> ~1.1M).
1217                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        // Continuous-posterior scorer (UNO_CONTINUOUS): replace the ~30-point
1268        // deviation-cut SWEEP with ONE weighted pass. Each peptide's `dev` becomes a
1269        // posterior P(correct) from the target/decoy dev distributions; proteins are
1270        // scored by weighted evidence. No hyperparameter scan, no threshold-selection
1271        // bias, and it uses the full `dev` gradient instead of a hard gate.
1272        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                // One row per (peptide, MS1 feature) match with mass/RT residuals,
1319                // so an external scorer/ML (ms1searchpy) gets the same per-PFM
1320                // evidence its own PFMs carry. feature = peak id (ids).
1321                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                    // recompute per-PFM mass/RT residuals against the winning calibration
1330                    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        // ms1searchpy ENSEMBLE: final_iteration runs the greedy at 10 inclusion
1360        // thresholds and scores each protein by the MEAN of its per-pass binomial
1361        // scores, instead of picking one best threshold. Accumulate (score_sum,
1362        // max_obs, theo) per protein across the cut sweep. UNO_ENSEMBLE.
1363        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        // Deviation-cut sweep extent. The sweep is a `par_iter` over cuts; the best
1369        // cut sits near ~6, and the high-dev tail cuts (>8) are BOTH the most expensive
1370        // tasks (they admit ~all peptides, so their build + greedy dominate the sweep
1371        // makespan) AND actively harmful: the raw best-cut-by-target-count selection
1372        // over-fits to a loose, decoy-heavy high cut. Capping at 8 drops them.
1373        // Measured: run10 BYTE-IDENTICAL to the old 25.0 cap (its best cut was <=8);
1374        // deephela 1954 -> 2044 honest @1% (+4.6%, the loose cut regularized away) with
1375        // the per-stage sweep roughly halved (~189s -> ~97s across 4 stages). Both
1376        // deterministic. `cut_step` (fine step over 1..12) and `cut_max` stay env-
1377        // tunable; on this bigger single-input the tail was pure cost + FDR noise.
1378        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        // Each deviation cut is an INDEPENDENT protein-inference pass: build the
1398        // matched-peptide sets at that cut, then run the greedy competition. This is
1399        // the dominant cost (a ~30-way greedy sweep). Fan the cuts across cores; the
1400        // `collect` preserves cut_grid order, so the downstream best-cut pick and the
1401        // ensemble accumulation stay byte-identical to the old serial loop.
1402        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                // Matched peptides at this deviation cut -> protein -> peptide set.
1407                // GREEDY PARSIMONY (ms1searchpy final_iteration): assign each peptide
1408                // to one protein and pair-delete target/decoy.
1409                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                // Peptide -> ALL MS1 features it matched at this cut (feature-level
1415                // competition: a peptide survives until every one of its peaks is
1416                // claimed, not just its single best PFM).
1417                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                        // Multi-feature retention (UNO_MULTI_FEAT) HURT honest depth;
1425                        // default = single best PFM (the single-feature reduction acts
1426                        // as a useful selectivity filter).
1427                        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                // ms1searchpy-style feature-level competition + dynamic background
1456                // rate is the default. UNO_NO_FEATURE_COMPETE=1 restores the old scorer.
1457                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        // Deterministic serial reduction over the ordered per-cut results: accumulate
1496        // ensemble sums in cut order, then pick the FIRST (smallest) cut achieving the
1497        // max target count -- identical to the old `if nt > best_n` scan.
1498        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            // Mean per-pass binomial score; pair-compete target/decoy; (d+1)/t FDR.
1534            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    // A rescued (top-score, q-unconstrained) bootstrap set is contaminated by
1606    // construction. That is fine for the median / RANSAC calibrators above, but
1607    // NOT for `est_rt_sig`, which reads the RT spread of the confident proteins'
1608    // peptides: the random proteins contribute the population spread and the
1609    // estimate inflates (1.60 min vs 0.21 from a clean set on the same run), the
1610    // deviation stage under-gates and collapses. Keep only the rescued proteins
1611    // that own at least one RANSAC-inlier anchor — the RT-consistent ones.
1612    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    // Stage 1: deviation on the mass-bootstrap calibration.
1630    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    // Stage 1 + the iterative multi-stage RT calibration (ms1searchpy drives the
1678    // random-match rate p 0.68->0.30->0.28->0.12 across stages; uno used to do ONE
1679    // recalibration and stop at p~0.23-0.44). Each stage recalibrates RT on the
1680    // CURRENT confident set, tightens rt_sig, and re-searches; the confident set
1681    // purifies each round so the gate tightens and p falls. Deterministic:
1682    // cal_pairs are total-ordered before the fit. Keeps the best-scoring stage.
1683    // `floor` is the RT-sigma floor (0 = none) and `seed` extra proteins added to
1684    // the stage-2 confident set; both are 0/empty on the normal path.
1685    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    // Candidate calibration runs (see the UNO_SMOOTH note above). Level 0: one
1758    // run at the estimated sigma, plus the seeded low-input retries when stage 1
1759    // collapses; the candidate with the most proteins at q<=1% wins and the ML
1760    // stage runs once on it. Levels >=1 add the 1.5x-sigma candidate, run the ML
1761    // stage on EVERY candidate, and pick by the final list.
1762    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    // Make the grid symmetric in log-sigma, but ONLY when the anchor set that
1780    // `est_rt_sig` is fitted on is too small to trust. est_rt_sig is a clipped_sigma
1781    // over the mass-anchor confident proteins; from a handful of them a few RT
1782    // outliers OVER-estimate it, and an over-wide gate admits enough random matches
1783    // to collapse the run to zero. With only 1.0x/1.5x there is no way back — every
1784    // candidate is at least as wide as the bad estimate. Measured on timsTOF Ultra 2
1785    // (17.9k features, anchor set 18 proteins, auto rt_sig 1.51): forced rt_sig
1786    // 1.5 -> 0 proteins, 1.2 -> 226, 0.9 -> 243, 0.6 -> 155; 2 of 12 Bruker runs
1787    // collapsed this way and the tightening candidate recovers all of them.
1788    // Gated because the extra candidate is not free above the threshold: Orbitrap
1789    // anchors 76+ proteins, est_rt_sig is well determined, and letting x0.67 compete
1790    // there is ~35% more scoring time for a wash on honest entrapment depth
1791    // (09734 946->975, 09746 876->889, but 09778 1202->1165; run10/09950/deephela/
1792    // faims unchanged) — it can win on uno's own decoy count while LOSING honest
1793    // depth. 40 sits between the two regimes (Bruker 14-18, Orbitrap 76+).
1794    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    // Low-input collapse (PXD066701 200 pg / 1 ng files, 2026-09-04): when stage 1
1811    // finds almost nothing at q<=1%, the stage loop has no confident set to
1812    // recalibrate on and the run exits EMPTY although the mass-anchor bootstrap
1813    // had a usable (small) confident set and a good RANSAC RT fit. Retry with the
1814    // bootstrap proteins seeding stage 2, once at the estimated sigma (which is
1815    // unreliable from <25 proteins) and once floored at 1.0 min. Only fires when
1816    // stage 1 collapses; every other run is unchanged.
1817    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    // The ML stage as a function of one calibration candidate (see below).
1829    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        // ms1searchpy's semi-supervised positive set is peptides of 25%-FDR proteins
1837        // (top_25_targets), NOT 1% -- a loose gate gives far more positive training
1838        // signal. (Validated: 25%-label ML peptide-filter -> 899->1073 honest @1%.)
1839        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            // One PFM per peptide: the MIN combined-deviation (mass_z^2 + rt_z^2) mono
1846            // feature under the winning calibration. Scoring every PFM and taking the
1847            // per-peptide MAX inflates random peptides (max-of-many); ms1searchpy
1848            // applies the ML to mass+RT-gated PFMs, so we gate to the best one here.
1849            let rt_sig_ml = est_rt_sig(best_cal, &conf, best_shift, best_gate).max(0.5);
1850            // combined_score gate on the ML CANDIDATE set (koth feature-quality ML
1851            // score). Junk low-quality features carry most of the coincidental yeast
1852            // matches that flood the mid-tier (@5%); a real human peptide keeps its
1853            // high-quality feature, a yeast peptide loses its lucky junk one. Applied
1854            // here (unlike UNO_CS_GATE which only touches run_deviation) because THIS
1855            // pep_best set feeds the qpred ensemble that produces the final count.
1856            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            // Carry this candidate's RT+mass-gated peptide→feature map with its
1906            // result. Publishing here would leak the last evaluated candidate's
1907            // evidence into the winner's grouping and quantification outputs.
1908            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            // Candidate set = mass+RT-gated peptides. The gate must be LOOSE: uno's CNN
1914            // RT is ~2 min MAE (only ~half of real peptides within 1 min), so a tight
1915            // dev gate drops exactly the loose-RT real peptides the ML is meant to
1916            // recover (ms1searchpy promotes these via quality/consistency features). A
1917            // tight gate pre-decides the answer; the held-out-decoy FDR controls the
1918            // looseness. Keep a generous ceiling and let the classifier rank.
1919            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            // `pep_best` is a std HashMap (randomized per-process iteration order),
1925            // so the collected row order is nondeterministic. LightGBM bagging picks
1926            // rows by index, so an unsorted order makes the trained model — and thus
1927            // the honest depth — vary run-to-run (observed 466..692 @1% for the SAME
1928            // config). Sort by peptide index to make the ML rescoring reproducible.
1929            items.sort_unstable_by_key(|&(pi, _, _, _)| pi);
1930            // IM is a soft feature only after the mass/RT shortlist is fixed.
1931            // It cannot reject candidates, change RT anchors, or rescue RT failures.
1932            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            // Per-peptide group consistency stats over ALL the peptide's PFM matches
1962            // (ms1searchpy's id_count/charge_count/im_count + within-group |dev|
1963            // normalization): a real peptide recurs across charges / scans with a
1964            // CONSISTENT mass+RT; random coincidences do not. This multi-feature
1965            // evidence is exactly what the single-best-PFM deviation score discards.
1966            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                                // Coherent matches only. Off-RT mass coincidences must
2001                                // not define this peptide's consistency statistics.
2002                                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            // Feature set = uno's PFM features + amino-acid COMPOSITION (20) +
2035            // proline-context dipeptides (DP/KP/RP) + the per-peptide group
2036            // consistency features. This is what ms1searchpy's `-ml 1` LightGBM uses.
2037            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                // biosaur2 discriminators (isotope-pattern fit error + peak width):
2101                // the real-vs-random signal absent from n_isotopes/n_scans. 0 when the
2102                // feature file lacks them (inert there).
2103                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                    // All original-quality columns come from ONE actual candidate
2123                    // observation. Cross-run summaries are separate learned features.
2124                    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                    // Original shape values stay attached to the selected observation.
2152                    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                        // The same row summary is used for every target or decoy
2168                        // peptide candidate; MBR does not create peptide observations.
2169                        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            // CV folds are PEPTIDE-disjoint (same peptide -> same fold): a peptide's
2183            // PFMs must never split across train/test or the model memorizes it.
2184            let folds: Vec<usize> = items
2185                .iter()
2186                .map(|&(pi, _, _, _)| fold_of(pi, cfg.n_folds, cfg.effective_cv_seed()))
2187                .collect();
2188            // SEMI-SUPERVISED labels (ms1searchpy's honesty mechanism): positives =
2189            // peptides of confident target proteins; negatives = dim target peptides;
2190            // DECOYS ARE EXCLUDED FROM TRAINING (label None) and held out purely to
2191            // estimate FDR. Training target-vs-decoy (the old scheme) leaks: real-but-
2192            // absent proteins look like targets in quality features, so the model
2193            // promotes them -> inflated FDR. Learning "what real IDs look like" from
2194            // confident-vs-dim targets keeps decoys a pristine null.
2195            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            // Nonlinear CV-gbdt (ms1searchpy uses LightGBM): the composition + group
2212            // consistency features interact nonlinearly, which a linear LDA cannot
2213            // capture (it collapsed to ~0 separation). Predicts ALL rows incl. the
2214            // held-out decoys for the FDR step.
2215            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                // Target-peptide depth at 1% peptide-level decoy-FDR (the number that
2236                // matters: how many target peptides outrank the decoys).
2237                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                        // label: 1=conf-target, 0=dim-target, -1=decoy ; ml = CV prediction; then feature vector
2261                        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            // The ML feature matrix is only needed to fit/score/diagnose the
2298            // qpred ensemble above; the rest of this stage ranks by `ml` scores and
2299            // `pfms`/`reduced`, so free it now (one Vec<f32> per candidate peptide).
2300            drop(feats);
2301            // ms1searchpy `final_iteration` (main.py:140, 1965-2010): NOT a single
2302            // peptide-quality cutoff. Rank peptides by ML score (best first), then
2303            // sweep 10 CUMULATIVE inclusion thresholds (top 10%, 20%, ..., 100% of
2304            // ranked peptides == ms1searchpy's `mass_koef` 0..9 on `qpreds`), run the
2305            // full protein engine (feature competition + dynamic p) at each threshold,
2306            // and score every protein by the MEAN of its binomial score across the 10
2307            // passes (missing passes count as 0 -- consistency across thresholds is
2308            // rewarded, exactly like the mass/RT-deviation UNO_ENSEMBLE below).
2309            // DECISIVE (2026-06-29, uno-ml-protein-scoring memory): collapsing this to
2310            // a SINGLE cutoff (the old peptide-filter here) throws away most of the
2311            // lever -- an isolated Python test went 1073 (10-step ensemble) -> 632
2312            // (single cutoff) on the identical ranked peptide list. This is that missing
2313            // ensemble, ported to Rust.
2314            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            // One row per peptide, ranked best-first. Default: ML score desc.
2350            // UNO_ENS_BY=dev ranks by mass+RT DEVIATION ascending instead -- this
2351            // mirrors ms1searchpy's `mass_diff <= mass_koef` sweep. The point: a
2352            // yeast/decoy peptide matching a human feature is a mass COINCIDENCE, so
2353            // its deviation is large; a real peptide matches tight. Sweeping the
2354            // deviation threshold therefore preferentially drops luck-matched false
2355            // peptides (the ML score does NOT -- DECOY_mean ~= dim_mean), which is
2356            // exactly the mid-tier (@5%) yeast contamination lever. UNO_ENS_BY=mix
2357            // blends: rank by ML but break ties / co-rank with deviation.
2358            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                // small dev = best; negate so the shared desc sort keeps it first.
2361                "dev" => items
2362                    .iter()
2363                    .map(|&(pi, decoy, _, dev)| (-dev, decoy, pi))
2364                    .collect(),
2365                // blend: standardize ml (desc) and dev (asc) to ranks, sum. Computed
2366                // below after we have both orderings; here just carry ml, dev via pi.
2367                "mix" => {
2368                    let n = items.len().max(1) as f64;
2369                    // rank by ml desc
2370                    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                    // rank by dev asc
2377                    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            // Ensemble cutoff sweep. The binomial background `p` is a global decoy
2398            // fraction, so LOOSE steps (keep 90-100%) admit many decoys -> p high ->
2399            // real peptide-rich proteins barely beat chance (scores collapse ~2.5 at
2400            // the FDR margin). Restricting to TIGHTER steps lowers the averaged p.
2401            // Env-tunable: UNO_ENS_STEPS resolution, UNO_ENS_LO..UNO_ENS_HI the
2402            // included step band.
2403            //
2404            // DEFAULT = steps=40, lo=1, hi=14: average only the 14 TIGHTEST of 40
2405            // inclusion bands (roughly the top 2.5-35% of ML-ranked peptides), dropping
2406            // the loose junk-heavy passes that inflate the random-match background p.
2407            // Validated 2026-07-20 on 2 large HeLa runs (run10 775->1218, deephela-240min
2408            // 1585->1752; neutral on a small run), dual-null calibrated (internal decoy
2409            // FDR and yeast-entrapment agree) and byte-deterministic. Takes uno from
2410            // behind ms1searchpy-DeepLC to a tie. Set `UNO_ENS_HI=10` with
2411            // `UNO_ENS_STEPS=10` for the old full uniform sweep (pre-2026-07-20).
2412            //
2413            // NOTE: this band was SELECTED by maximizing yeast-entrapment depth on
2414            // run10 and deephela, which are development datasets from the same family
2415            // as the published benchmark. The manuscript discloses it as a tuned
2416            // default; do not present it as an independent result.
2417            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            // ms1searchpy's peptide filter (UNO_ML_DECOYCUT): instead of averaging a
2433            // 10-step cutoff ensemble, keep peptides in the top ML-quality quantile
2434            // bins up to where the per-bin DECOY fraction crosses `ml_correction`
2435            // (~0.5 for a balanced target/decoy db), then run ONE protein-binomial
2436            // pass. This is a loose, decoy-CALIBRATED cutoff (ms1 feeds ~19.4k peptides
2437            // vs uno's ensemble ~15.7k). `rows` is already ml-desc, one row/peptide.
2438            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            // The inclusion steps are INDEPENDENT protein-inference passes -> run them
2493            // in parallel (each the dominant per-pass greedy cost). Results collect in
2494            // `steps` order, and the `acc` accumulation is then done SERIALLY in that
2495            // same order, so the float sums stay bit-identical to the old serial loop.
2496            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            // Average per-pass binomial score; pair-compete target/decoy; (d+1)/t FDR
2532            // -- identical post-processing to the UNO_ENSEMBLE deviation path.
2533            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            // VALIDATED (2026-07-07, human+yeast entrapment, PXD015669 QEHF1_09744_JB
2592            // native biosaur2 features): 223 real/4 yeast (deviation) -> 251 real/1
2593            // yeast (this ensemble) -- deeper AND more honest, not just deeper. The
2594            // `ml_n > best_n` check is itself a safe no-regression guard (only swaps
2595            // in a strictly deeper result), so no separate opt-in env var is needed.
2596            // If a feature file lacks the quality columns (isoerror/isoerror2 etc.)
2597            // the gbdt has no real signal over decoys (dim_mean ~= DECOY_mean) and
2598            // this correctly no-ops.
2599            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    // Finalize: default picks the candidate with the most proteins at q<=1% (first
2611    // wins ties, as before) and runs ML once; UNO_SMOOTH finalizes every candidate.
2612    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                    // Preserve the deviation list and this same calibration's
2624                    // peptide evidence when its ML rescore does not improve it.
2625                    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    // Publish only the selected candidate's evidence. Evaluated losers must not
2740    // change protein grouping, peptide intensities, or LFQ feature assignments.
2741    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    /// Confident proteins from the mass-anchor mass+RT binomial, handed to `score`
2758    /// out-of-band (the seed function shares its signature with the legacy path).
2759    static MASS_ANCHOR_PROTEINS: std::cell::RefCell<Vec<crate::protein::ProteinId>> =
2760        const { std::cell::RefCell::new(Vec::new()) };
2761    /// RT+mass-gated best feature per peptide (the `pep_best` selection), handed
2762    /// out-of-band for the multi-sample quant rollup. `(peptide_idx, feature_idx,
2763    /// decoy)`. This is the feature uno actually trusts for the peptide (mass AND
2764    /// RT consistent), NOT the best-mass feature — the distinction matters for
2765    /// quant, where a mass-coincident contaminant would corrupt the intensity.
2766    static MASS_ANCHOR_PEPTIDES: std::cell::RefCell<Vec<(u32, u32, bool)>> =
2767        const { std::cell::RefCell::new(Vec::new()) };
2768}
2769
2770/// One RT+mass-gated peptide→feature hit, exposed for the quant rollup.
2771#[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
2778/// Reduce all PFMs to the top-K best-mass-error targets and decoys per feature.
2779fn reduce_top_k(pfms: &[Pfm], n_features: usize, k: usize) -> Vec<usize> {
2780    // best |mass error| per feature, separately for targets and decoys
2781    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
2807/// Same peptide-disjoint CV / semi-supervised-label plumbing as
2808/// [`cv_train_predict`], but trains a real LightGBM classifier (via
2809/// `uno-lightgbm-sys`) instead of the pure-Rust `gbdt` crate. uno's own gbdt
2810/// crate has repeatedly failed to separate decoys from dim-real peptides on
2811/// this feature set (`ML diag: dim_mean ~= DECOY_mean` in the mass-anchor log,
2812/// see `ML_RESCORE_PORT.md`); LightGBM natively supports {0,1} binary labels
2813/// (no gbdt's {-1,+1} degenerate-gradient workaround) and is what
2814/// ms1searchpy itself uses, so this removes uno's own classifier as a
2815/// confound when judging whether the qpred-ensemble architecture is the
2816/// remaining lever.
2817/// Train on all folds != `test_fold` (pos=conf, neg=dim capped 3x, decoys
2818/// excluded) and write held-out predictions into `scores` for the test-fold rows.
2819/// Optionally rank-normalizes the test-fold predictions to `[0,1]` (ms1searchpy's
2820/// `rankdata(...)/n` — makes the per-fold scores comparable and robust to the raw
2821/// LightGBM output scale, which is what lets strong quality features help instead
2822/// of destabilize).
2823fn 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        // ordinal rank / n within the fold (deterministic tie-break by index).
2890        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
2907/// Pure-Rust twin of [`lgbm_fold`] using `sequoia-boost` (an XGBoost reimplementation)
2908/// instead of the C LightGBM. IDENTICAL CV split, negative balancing (3x cap, same
2909/// hash order) and hyperparameters (binary:logistic, hist, depth/lr/iters, 0.8 row +
2910/// col subsample, seed 42, requested nthread=1). Only the tree library
2911/// differs. Gated by `UNO_ML_SEQUOIA`.
2912fn 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    // Env-tunable hyperparameters (for the tuning sweep). depth/lr/iters come from the
2972    // caller (UNO_ML_DEPTH/LR/ITERS); these are sequoia/XGBoost-specific knobs.
2973    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) // Current Sequoia uses the ambient Rayon pool despite this setting.
2999        .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(&params, &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
3057/// AUC (Mann-Whitney) of conf(label 1) vs dim(label 0) over labeled rows; decoys
3058/// (label None) excluded. Used to score a hyperparameter set during the search.
3059fn 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
3094/// Deterministic random LightGBM param string for search trial `t` (ms1searchpy's
3095/// grid: num_leaves, learning_rate, min_data, feature/bagging fractions, L1/L2).
3096fn 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; // 15..500
3109    let lr = 0.01 + u() * 0.19; // 0.01..0.20
3110    let min_data = (20.0 + u() * 480.0) as usize; // 20..500
3111    let ff = 0.4 + u() * 0.6; // 0.4..1.0
3112    let bf = 0.5 + u() * 0.5; // 0.5..1.0
3113    let l1 = u(); // 0..1
3114    let l2 = u(); // 0..1
3115    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    // ms1searchpy's per-dataset hyperparameter random search + peptide-disjoint CV
3134    // + rank-normalized predictions. UNO_ML_TUNE = number of search trials (default
3135    // 12; 0 = fixed-param legacy path). Each trial is scored by held-out conf-vs-dim
3136    // AUC on fold 0; the winner is refit over the full CV with rank-normalized preds.
3137    // Default OFF: the ms1-style search+CV+rank-norm (UNO_ML_TUNE=N) STABILIZES the
3138    // ML on strong quality features (aug/cosine-isotope 551->728, no longer collapses)
3139    // but does NOT raise the ceiling (plain koth 731->618) — uno's conf-vs-dim AUC caps
3140    // at ~0.649 regardless of hyperparameters, so the gap to ms1 (~1300) is FEATURE/
3141    // LABEL-limited, not tuning-limited. Kept env-gated for the feature-engineering work.
3142    let n_trials: usize = std::env::var("UNO_ML_TUNE")
3143        .ok()
3144        .and_then(|s| s.parse().ok())
3145        .unwrap_or(0);
3146    // depth 7 was the empirical fixed-path best on run10 (705@d6 -> 731@d7).
3147    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        // DEFAULT ML backend = pure-Rust sequoia-boost (XGBoost clone), tuned mcw=80.
3169        // Validated 2026-07-21 (honest @1%): run10 sequoia 1218 = LightGBM 1218;
3170        // deephela-240min sequoia 1954 > LightGBM 1752 (+11.5%). Pure-Rust (drops the
3171        // vendored C lib_lightgbm.so) and byte-deterministic. Set UNO_ML_LIGHTGBM=1 to
3172        // fall back to the C LightGBM trainer.
3173        if !std::env::var("UNO_ML_LIGHTGBM").is_ok() {
3174            // Same byte-identical fold parallelization as the LightGBM path below: each
3175            // fold is seed-pinned and writes only
3176            // its own held-out rows, so training the folds concurrently and merging by
3177            // row-ownership reproduces the serial scatter exactly.
3178            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        // The folds are INDEPENDENT (each trains on its complement and writes only its
3210        // own held-out test rows) and each LightGBM booster is single-threaded &
3211        // seed-pinned, so training them concurrently is byte-identical to the serial
3212        // loop -- only much faster (3 sequential single-thread trainings -> 3 parallel).
3213        // Each fold writes into its own buffer; the merge is by ownership (row i is a
3214        // test row of exactly fold `folds[i]`), so it reproduces the serial scatter
3215        // exactly (no float-equality heuristics).
3216        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, &params, 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    // Search: evaluate each trial on fold 0 (train folds 1..k, predict fold 0), pick
3231    // the highest held-out AUC.
3232    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, &params, 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    // Final: full CV with the winning params + rank-normalized per-fold predictions.
3261    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            &params,
3268            iters,
3269            f,
3270            true,
3271            &mut scores,
3272        );
3273    }
3274    scores
3275}
3276
3277/// Robust spread (sigma estimate) of a centered-at-zero deviation sample, via
3278/// the median absolute value scaled to a Gaussian sigma. Resistant to the heavy
3279/// contamination in the positive seed.
3280fn 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    // |x| median -> sigma; guard against a degenerate zero spread.
3288    (mad / 0.6745).max(1e-9)
3289}
3290
3291/// Sigma of the Gaussian CORE of a centered sample, via iterative sigma-clipping.
3292/// Real mass/RT errors form a tight Gaussian on a flat random-match background;
3293/// MAD is inflated by that background. Restricting to |x| <= 2.5σ and recomputing
3294/// the std converges to the true peak sigma (ms1searchpy fits a Gaussian for this).
3295fn clipped_sigma(vals: impl Iterator<Item = f64>) -> f64 {
3296    let mut v: Vec<f64> = vals.filter(|x| x.is_finite()).collect();
3297    // Sort before reducing. Every caller builds its input by iterating a HashMap,
3298    // so the values arrive in process-random order, and the two `sum::<f64>()`
3299    // below are not associative in floating point: the same sample gives a
3300    // slightly different sigma run to run. That sigma feeds the `dev <= cut`
3301    // acceptance, so the low bits reach the accepted protein set. Sorting also
3302    // makes the summation more accurate, since the magnitudes are then adjacent.
3303    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
3329/// ms1searchpy's `noisygaus` calibration, faithfully: fit `a*exp(-(x-x0)^2/2s^2)+b`
3330/// (Gaussian peak on a flat random-match baseline) to a histogram of the residuals,
3331/// return (x0=shift, |s|=sigma). ms1 uses scipy `curve_fit` (Levenberg-Marquardt);
3332/// here a grid over (x0, s) with the closed-form least-squares (a,b) per grid point
3333/// (the model is linear in a,b), minimizing SSE — deterministic, no LM needed.
3334/// `vals` = the raw residuals (e.g. rt_diff of ALL matched PFMs, ms1's input — NOT
3335/// the best-per-peptide order statistic, which collapses the peak).
3336fn 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    // grid over x0 near the median, s in [0.1, span/2]; fit (a,b) by least squares.
3358    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            // g_i = exp(-(c_i - x0)^2 / 2 s^2); model h_i ~ a*g_i + b. Solve 2x2 normal eqs.
3365            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
3396/// Gaussian-CORE sigma via histogram half-width-at-half-maximum. Real RT/mass
3397/// residuals form a narrow Gaussian on a WIDE flat random-match background; MAD /
3398/// sigma-clipping stay inflated by that background (uno's `clipped_sigma` pins at
3399/// the 3.5 clamp), giving a too-loose gate that admits decoy matches. This finds
3400/// the narrow peak directly (ms1searchpy's `noisygaus` fit): histogram, locate the
3401/// mode, subtract the flat baseline, and measure the half-max half-width.
3402fn 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    // center on the median, histogram over [-span, span].
3408    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; // odd -> a center bin
3412    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    // light 3-bin smoothing.
3423    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    // flat background = median of the outer 40% of bins (tails).
3435    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    // walk out from the peak to the half-max crossing on each side.
3448    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) // FWHM = 2.355 sigma -> HWHM = 1.1774 sigma
3458}
3459
3460/// Run the full scoring + FDR pipeline. Returns one [`Identification`] per
3461/// feature that matched at least one peptide.
3462pub 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    // Penultimate embeddings per unique peptide_idx, captured in the SAME pass that
3481    // predicts abstract RT below (the on-data RT fine-tune re-embeds exactly these
3482    // peptides, so a separate embed pass would repeat ~1.1M CNN forwards). Filled
3483    // only when the fine-tune will consume them; reused at the rt_feat build.
3484    // Abstract RT per reduced PFM. Prefer a precomputed DeepLC library (sequence-
3485    // aware, generalizes — the discriminating signal); fall back to the distilled
3486    // model. Either is then calibrated on-data to this gradient.
3487    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                // Predict once per UNIQUE peptide_idx, then scatter back. The reduced
3511                // set repeats each peptide across many features (~9M PFMs, ~1M unique),
3512                // so deduplicating cuts RT inference ~9x.
3513                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    // uno's deliverable is the mass-anchor protein set. It needs only the reduced
3542    // PFMs + the abstract RT computed above: RT calibration, mass recalibration,
3543    // the iterative protein-bootstrap, and the per-protein binomial all run inside
3544    // `mass_anchor_calibration`, which fills the MASS_ANCHOR_PROTEINS thread-local.
3545    // RT is required — without it there is no protein evidence and no result.
3546    let has_rt = abstract_rt.iter().any(|a| a.is_some());
3547    // Precompute the entire target+decoy library before RT/IM rescoring.
3548    // Only charges with usable observed IM can consume these predictions.
3549    // The downstream shortlist and its per-charge calibration stay unchanged.
3550    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        // Early calibration exits publish nothing; each pass starts without
3568        // evidence from an earlier pass or search on this thread.
3569        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        // The first pass retains the exact legacy empty-cut behavior.
3655        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    /// A losing candidate evaluated later must never replace the winner's
3684    /// peptide-to-feature mapping used for grouping and quantification.
3685    #[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        // A broad training list has greater soft depth but no reportable hits.
3703        // This reproduces the low-input seed-3 regression in candidate selection.
3704        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        // When every candidate has zero discoveries, retain useful ranked
3712        // evidence; no confidence threshold is weakened to manufacture hits.
3713        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        // Experimental modes can produce training-only legacy rankings. The
3725        // first pass must preserve their original selection semantics too.
3726        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    /// Ties retain the first candidate's entire result, while a later winner
3922    /// carries its own map; scoring mode changes must not change ownership.
3923    #[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    /// `clipped_sigma` must not depend on the order it sees its sample.
3945    ///
3946    /// Every caller builds the input by iterating a `HashMap`, so the order is
3947    /// process-random, and the function reduces with `sum::<f64>()`, which is not
3948    /// associative in floating point. The resulting sigma feeds the `dev <= cut`
3949    /// acceptance, so a low-bit difference reaches the accepted protein set. This
3950    /// is the regression test for that fix: shuffled input, bit-identical output.
3951    #[test]
3952    fn clipped_sigma_is_order_invariant() {
3953        // A tight Gaussian-ish core plus a flat background, i.e. the shape the
3954        // function exists to handle, and large enough to pass its n >= 20 gate.
3955        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        // Deterministic shuffles (no RNG: the test must not need one).
3969        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    /// The same property for the degenerate branch (n < 20 -> `robust_sigma`).
3987    #[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}