Skip to main content

koth_ms/lfq/
mod.rs

1pub mod consensus;
2pub mod grid;
3pub mod integrate;
4mod ownership;
5pub mod rescore;
6pub mod score;
7pub mod targets;
8pub mod tdc;
9
10use crate::scoring::model::IsotopeModelSpec;
11use std::collections::HashMap;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::time::Instant;
14
15use rayon::prelude::*;
16use serde::{Deserialize, Serialize};
17
18use crate::alignment::{AlignmentResult, RunInput};
19
20use consensus::{build_consensus, ConsensusConfig, ConsensusFeature};
21use grid::{build_grid_excluding, SampleId, SortedHills, XicGrid};
22use integrate::{integrate, PeakResult};
23use ownership::CellCandidate;
24use score::{score_grid, ColumnScores};
25use std::collections::HashSet;
26
27use crate::models::Hill;
28use crate::scoring::elements::K_PATTERN;
29
30/// Convert peak apex/start/end bins into native-space RT statistics.
31/// Returns `(apex_rt, peak_width_rt)`; both NaN/0.0 when no peak was found.
32fn peak_rt_stats(grid: &XicGrid, n_cols: usize, peak: &PeakResult) -> (f64, f64) {
33    if peak.intensity <= 0.0 || n_cols == 0 {
34        return (f64::NAN, 0.0);
35    }
36    let span = grid.rt_max - grid.rt_min;
37    if span <= 0.0 {
38        return (f64::NAN, 0.0);
39    }
40    let bin_w = span / n_cols as f64;
41    let apex_rt = grid.rt_min + (peak.apex_bin as f64 + 0.5) * bin_w;
42    // Inclusive bin range → +1 bin worth of width.
43    let width = (peak.end_bin as f64 - peak.start_bin as f64 + 1.0) * bin_w;
44    (apex_rt, width)
45}
46
47/// Configuration for LFQ grid extraction and peak integration.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(default, deny_unknown_fields)]
50pub struct LfqConfig {
51    /// m/z tolerance for hill lookup (ppm)
52    pub mz_ppm: f64,
53    /// Half-window size as a fraction of the run's total RT span.
54    /// 0.005 = ±0.5% of the observed RT span → 1% total window, centred on
55    /// the alignment-predicted native RT. Default 0.005.
56    pub rt_window_pct: f64,
57    /// Ion mobility half-window for hill lookup (absolute 1/K0 units).
58    /// Default 0.015; inert when the input has no ion mobility.
59    pub im_tolerance: f64,
60    /// Number of isotopologue rows: 1 = M only, 2 = M+M1, 3 = M+M1+M2
61    pub n_isotopes: usize,
62    /// Retention-time bins across the extraction window. This should track the
63    /// run's MS1 scan density: on a two-hour Orbitrap gradient the default
64    /// window holds roughly fifteen real scans, so a finer grid spreads them
65    /// over mostly empty bins and the per-column statistics that drive scoring
66    /// and peak bounds become noise. Measured on IonStar, dropping from 100 to
67    /// 25 cuts the paired CV difference against FlashLFQ from 2.98 to 0.76
68    /// points and improves identification-free median CV from 16.58% to 14.00%.
69    /// A faster-scanning instrument supports more bins; deriving this from the
70    /// observed scan rate instead of a fixed count is the proper fix.
71    pub grid_cols: usize,
72    /// Target MS1 scans averaged into each retention-time column. When above
73    /// zero the column count is derived per run from the measured scan spacing
74    /// as `window / (target * spacing)`, so the amount of smoothing per column
75    /// is the same on a fast instrument as on a slow one, and `grid_cols`
76    /// becomes an upper bound. A column fed by a single scan carries that
77    /// scan's noise straight into the isotope and hybrid statistics that choose
78    /// the peak bounds, which is what a fixed column count produced here: on
79    /// these gradients 100 columns worked out to 1.01 scans each.
80    #[serde(default = "default_grid_scans_per_column")]
81    pub grid_scans_per_column: f64,
82    /// Furthest a peak may expand from its apex, as a fraction of the grid's
83    /// half-width. This used to be a fixed twenty bins, which silently coupled
84    /// the integration width to the grid resolution: the same setting meant
85    /// 0.28 minutes at 100 columns and 0.07 at 400, so raising the resolution
86    /// truncated peaks. Expressed against the window it means the same span
87    /// whatever the column count. One allows expansion across the half-window,
88    /// leaving the isotope and hybrid gates as the operative control.
89    #[serde(default = "default_peak_max_halfwidth_frac")]
90    pub peak_max_halfwidth_frac: f64,
91    /// Minimum spectral **Bhattacharyya** score for a grid column to keep
92    /// extending the integration peak (peak-expansion gate). Named for the
93    /// metric it actually uses — it is NOT a cosine threshold. Optional
94    /// (defaults to 0.1 when omitted); the config was previously named
95    /// `spectral_cosine_min`, which now errors (see `deny_unknown_fields`).
96    #[serde(default = "default_min_spectral_bhattacharyya")]
97    pub min_spectral_bhattacharyya: f64,
98    /// Scoring mode used to find the best integration window
99    pub score_mode: ScoreMode,
100    // NOTE on isotope scoring (this used to be a source of confusion): there
101    // are two DISTINCT isotope signals, both ALWAYS computed, no config flags:
102    //   * spectral Bhattacharyya — observed vs theoretical isotope PATTERN.
103    //   * co-elution cosine       — cosine between the matched isotopes' XIC
104    //                               traces across RT (do they co-elute?).
105    // hybrid = (rt · intensity · bhattacharyya · coelution)^¼. The old
106    // `spectral_bhattacharyya` and `spectral_coelution` flags are gone;
107    // leftover values in an existing TOML now error (see `deny_unknown_fields`).
108    /// Whether to run target-decoy competition and compute q-values
109    pub run_tdc: bool,
110    /// Decoy m/z shift in Da, added to the target m/z and divided by charge
111    /// (so the decoy m/z = target_mz + decoy_mz_shift_da / charge). Must be
112    /// large enough to fall outside any plausible isotopologue or adduct
113    /// envelope of the target.
114    #[serde(default = "default_decoy_mz_shift_da")]
115    pub decoy_mz_shift_da: f64,
116    /// Decoy RT shift as a fraction of the run's RT span, subtracted from the
117    /// target RT. The default 0.01 is twice the default extraction half-window,
118    /// so the target and decoy RT windows meet at one boundary. A shift larger
119    /// than twice `rt_window_pct` fully separates their RT intervals.
120    #[serde(default = "default_decoy_rt_shift_pct")]
121    pub decoy_rt_shift_pct: f64,
122    /// Tolerances for building the multi-run consensus feature list.
123    #[serde(default)]
124    pub consensus: ConsensusConfig,
125    /// Cross-run intensity normalization applied to the assembled matrix.
126    /// "none"           = raw intensities (original; downstream must normalize).
127    /// "median_ratios"  = DESeq/edgeR-style size factors: each run is scaled by
128    ///                    the median, over features detected in ALL runs, of its
129    ///                    log2 deviation from the per-feature mean. Robust to a
130    ///                    fraction of genuinely-changing features, unlike a plain
131    ///                    column-median (which is biased when a sizeable subset of
132    ///                    peptides changes systematically). Default "none".
133    #[serde(default = "default_normalize")]
134    pub normalize: String,
135    /// Per-cell intensity estimator for the consensus matrix.
136    /// "apex" = apex intensity (detected: feature `intensityApex`; MBR: total
137    ///          isotopologue intensity at the apex grid column). Default since
138    ///          0.10.0: lower fold-change error than "sum" on IonStar and on a
139    ///          held-out cohort (see docs/CONFIGURATION.md §4.2).
140    /// "sum"  = integrated peak area (detected: feature `intensitySum`; MBR:
141    ///          summed XIC grid over `start..=end`). The default before 0.10.0.
142    #[serde(default = "default_quant_estimator")]
143    pub quant_estimator: String,
144    /// Quantify EVERY consensus cell (detected and MBR) by the same grid
145    /// re-integration, instead of using the per-run feature's own intensity for
146    /// detected cells. Keeps detected and MBR cells on one commensurate scale —
147    /// essential on timsTOF, where the feature integrates the ion-mobility
148    /// dimension but the 2-D XIC grid does not, so mixing the two inflates CV,
149    /// and validated as a win on Orbitrap too (PXD003881: gated matrix CV
150    /// 14.27 → 12.31 %, ECOLI bias −0.067 → −0.020, HUMAN IQR 0.219 → 0.194).
151    /// Default TRUE since 2026-08-27: one estimator, one scale, everywhere.
152    /// Legacy false values now warn and use grid intensities: exclusive native
153    /// signal ownership cannot validate a feature-intensity override.
154    #[serde(default = "default_detected_use_grid")]
155    pub detected_use_grid: bool,
156    /// Replace the raw RT closeness term in the hybrid score with a
157    /// σ-normalised Gaussian RT likelihood `exp(−½ z²)`, where
158    /// `z = (column RT − predicted RT) / σ_local(RT)` and `σ_local` is the
159    /// post-warp RT-residual spread estimated per run from the RANSAC inlier
160    /// anchors (see `alignment::RtSigmaModel`). Makes the RT term of the
161    /// target-decoy discriminant region-aware: strict where alignment is
162    /// confident, tolerant where it is not. Applied identically to target and
163    /// decoy cells so the TDC null stays calibrated. Non-reference runs only;
164    /// falls back to the raw term when no σ model is available. Default false.
165    #[serde(default)]
166    pub rt_spread_scoring: bool,
167    /// Experimental search-guided scorer; legacy preserves the release path.
168    #[serde(default)]
169    pub search_scoring: rescore::search::SearchScoring,
170    /// Rescue unambiguous same-run IDs when global RT observations conflict.
171    /// Ambiguous targets remain ineligible for cross-run transfer.
172    #[serde(default)]
173    pub search_rt_rescue: bool,
174    /// Decide transfer eligibility per recipient run rather than per target.
175    /// Without this, pruning one run's ambiguous IDs withdraws the peptide from
176    /// transfer in every run, including runs whose chromatography was clean.
177    /// Requires `search_rt_rescue`; runs whose own IDs were withheld as
178    /// unsupported stay ineligible, and a target whose retained IDs still
179    /// disagree across runs remains globally ineligible because its consensus
180    /// coordinate is what a transfer would be centred on.
181    #[serde(default)]
182    pub search_rt_rescue_per_run_transfers: bool,
183    /// Experimental inferred 2+/3+/4+ targets, with explicit charge provenance.
184    #[serde(default)]
185    pub search_expand_charges: bool,
186    /// Apply the extraction q-value gate only to transferred cells. A cell with
187    /// an accepted same-run MS2 identification has already passed peptide-level
188    /// FDR; gating it again on an exploratory extraction score re-litigates an
189    /// identification that was accepted, and discards a quantity for a peptide
190    /// known to be present in that run. Transfers, which have no such evidence,
191    /// stay gated. FlashLFQ applies FDR to its match-between-runs peaks only.
192    /// Identification-free mode is unaffected: every cell there is inferred, so
193    /// every cell stays gated.
194    #[serde(default)]
195    pub search_gate_transfers_only: bool,
196    /// Report the *averagine-projected* intensity per cell instead of the raw
197    /// box-sum. For each grid column the observed isotopologue vector is passed
198    /// through a matched filter for the theoretical averagine pattern (the same
199    /// mass-derived fingerprint the Bhattacharyya score already uses):
200    /// `proj = <observed, pattern_hat>`, where `pattern_hat` is the L2-normalised
201    /// pattern. As a matched filter this maximises SNR for on-pattern signal
202    /// (recovering faint peptides the raw sum buries in noise) and rejects the
203    /// component of each column orthogonal to the fingerprint (intensity
204    /// distributed unlike averagine — interference, chemical noise). It is NOT a
205    /// pattern-match gate: gross mismatches (lone monoisotopes, wrong charge)
206    /// are still rejected by the Bhattacharyya window gate and the TDC q-value,
207    /// unchanged. The per-peptide constant `‖pattern‖` factor cancels in
208    /// cross-run ratios and CV, so LFQ ratios/CV are unaffected in scale. Apex
209    /// selection and window expansion stay on the raw signal / hybrid score;
210    /// only the reported value changes. Default false (byte-identical raw
211    /// box-sum). Mass-based, ID-free. Effect on the benchmark is unmeasured —
212    /// this is an opt-in knob to be validated, not a new default.
213    #[serde(default)]
214    pub averagine_projection: bool,
215    /// (Experiment B / audit A2) Score each **decoy** cell against an averagine
216    /// isotope template and theoretical pattern computed from the DECOY's own
217    /// shifted mass (`neutral_mass + decoy_mz_shift_da`) rather than the target's
218    /// mass. When false the decoy reuses the target's template. When true
219    /// (the default) the +11 Da decoy is judged against the isotope envelope it
220    /// actually sits on, removing the target-template freebie a mis-massed
221    /// decoy otherwise inherits on the Bhattacharyya (QDA feature 2) and the
222    /// co-elution theory weighting (feature 3). Applies only to the TDC decoy
223    /// grid; target scoring and all reported intensities are unchanged.
224    #[serde(default = "default_decoy_own_template")]
225    pub decoy_own_template: bool,
226    /// (Experiment B / audit A4) Co-elution value assigned to a grid cell with
227    /// fewer than two isotope rows carrying signal (a lone monoisotope — nothing
228    /// to co-elute). Applied identically to target and decoy cells. Default 0.5
229    /// (neutral). 1.0 gives an un-judgeable cell the maximal, target-like
230    /// score, which hands noise-grabbing lone-hill decoys a free target-like
231    /// coordinate on QDA feature index 3.
232    #[serde(default = "default_lone_coelution")]
233    pub lone_coelution: f64,
234    /// Which analyte class's average composition the LFQ isotope templates are
235    /// built from. Mirrors `[features] isotope_model` and must match the model
236    /// the per-run features were detected with, or the consensus cells are
237    /// scored against a pattern the detector never used. Default `"peptide"`.
238    #[serde(default)]
239    pub isotope_model: IsotopeModelSpec,
240}
241
242fn default_min_spectral_bhattacharyya() -> f64 {
243    0.1
244}
245
246fn default_lone_coelution() -> f64 {
247    0.5
248}
249
250fn default_decoy_own_template() -> bool {
251    true
252}
253
254fn default_normalize() -> String {
255    "none".to_string()
256}
257
258/// Median MS1 scan spacing in a run, measured from hills that span at least two
259/// scans. Returns `None` when no hill carries usable timing, in which case the
260/// configured column count stands.
261pub(crate) fn median_scan_spacing(hills: &[Hill]) -> Option<f64> {
262    let mut spacings: Vec<f64> = hills
263        .iter()
264        .filter_map(|h| {
265            let scans = h.scan_end.checked_sub(h.scan_start)?;
266            let span = h.rt_end - h.rt_start;
267            (scans >= 2 && span > 0.0).then(|| span / scans as f64)
268        })
269        .filter(|v| v.is_finite() && *v > 0.0)
270        .collect();
271    if spacings.is_empty() {
272        return None;
273    }
274    let mid = spacings.len() / 2;
275    spacings.select_nth_unstable_by(mid, f64::total_cmp);
276    Some(spacings[mid])
277}
278
279fn default_grid_scans_per_column() -> f64 {
280    4.0
281}
282
283fn default_peak_max_halfwidth_frac() -> f64 {
284    1.0
285}
286
287fn default_quant_estimator() -> String {
288    "apex".to_string()
289}
290
291fn default_detected_use_grid() -> bool {
292    true
293}
294
295fn default_decoy_mz_shift_da() -> f64 {
296    11.0
297}
298
299fn default_decoy_rt_shift_pct() -> f64 {
300    0.01
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
304#[serde(rename_all = "snake_case")]
305pub enum ScoreMode {
306    Hybrid,
307    Rt,
308    Intensity,
309    Spectral,
310}
311
312impl Default for LfqConfig {
313    fn default() -> Self {
314        Self {
315            mz_ppm: 10.0,
316            rt_window_pct: 0.005,
317            im_tolerance: 0.015,
318            n_isotopes: 3,
319            grid_cols: 100,
320            grid_scans_per_column: default_grid_scans_per_column(),
321            peak_max_halfwidth_frac: default_peak_max_halfwidth_frac(),
322            min_spectral_bhattacharyya: 0.1,
323            score_mode: ScoreMode::Hybrid,
324            run_tdc: true,
325            decoy_mz_shift_da: default_decoy_mz_shift_da(),
326            decoy_rt_shift_pct: default_decoy_rt_shift_pct(),
327            consensus: ConsensusConfig::default(),
328            normalize: default_normalize(),
329            quant_estimator: default_quant_estimator(),
330            detected_use_grid: default_detected_use_grid(),
331            rt_spread_scoring: false,
332            search_scoring: rescore::search::SearchScoring::Legacy,
333            search_rt_rescue: false,
334            search_rt_rescue_per_run_transfers: false,
335            search_expand_charges: false,
336            search_gate_transfers_only: false,
337            averagine_projection: false,
338            decoy_own_template: default_decoy_own_template(),
339            lone_coelution: default_lone_coelution(),
340            isotope_model: IsotopeModelSpec::default(),
341        }
342    }
343}
344
345#[cfg(test)]
346mod norm_tests {
347    use super::apply_median_ratio_normalization;
348
349    /// A uniform per-run scale factor must be fully removed (columns equalize
350    /// for every complete-case feature).
351    #[test]
352    fn median_ratio_removes_uniform_scale() {
353        let (n_features, n_runs) = (3usize, 3usize);
354        // run 1 is uniformly 2x brighter than runs 0 and 2.
355        let mut t = vec![
356            100.0, 200.0, 100.0, //
357            300.0, 600.0, 300.0, //
358            50.0, 100.0, 50.0,
359        ];
360        let mut d = vec![0.0f64; n_features * n_runs];
361        let sf = apply_median_ratio_normalization(&mut t, &mut d, n_features, n_runs);
362        assert!(
363            (sf[1] - 1.0).abs() < 1e-9,
364            "run 1 size factor should be +1 log2, got {}",
365            sf[1]
366        );
367        for feat in 0..n_features {
368            let b = feat * n_runs;
369            assert!(
370                (t[b] - t[b + 1]).abs() < 1e-6 && (t[b] - t[b + 2]).abs() < 1e-6,
371                "columns should equalize after removing the 2x scale"
372            );
373        }
374    }
375
376    /// Features missing in some run (0 intensity) are excluded from the size
377    /// factor (complete-case only) and left untouched if zero.
378    #[test]
379    fn median_ratio_ignores_incomplete_features() {
380        let (n_features, n_runs) = (2usize, 2usize);
381        let mut t = vec![100.0, 100.0, 500.0, 0.0]; // 2nd feature missing in run 1
382        let mut d = vec![0.0f64; 4];
383        let sf = apply_median_ratio_normalization(&mut t, &mut d, n_features, n_runs);
384        // only the complete feature (equal) drives the factor -> no scaling
385        assert!(sf[0].abs() < 1e-9 && sf[1].abs() < 1e-9);
386        assert_eq!(t[3], 0.0, "missing cell stays zero");
387    }
388}
389
390/// Compute DESeq/edgeR-style size factors from the assembled target matrix and
391/// apply them in place to both the target and decoy intensities.
392///
393/// A run's size factor is the median, over features present (> 0) in EVERY run,
394/// of `log2(intensity) - mean_over_runs(log2(intensity))`. Factors are centred
395/// so the median run is unscaled (overall intensity scale preserved). Returns
396/// the centred per-run log2 size factors for logging.
397///
398/// Robust to a fraction of genuinely-changing features because the median
399/// ignores the tails where real biology lives — unlike a plain column-median,
400/// which shifts with the changing subset.
401fn apply_median_ratio_normalization(
402    intensities: &mut [f64],
403    decoy_intensities: &mut [f64],
404    n_features: usize,
405    n_runs: usize,
406) -> Vec<f64> {
407    if n_runs < 2 {
408        return vec![0.0; n_runs];
409    }
410    let mut ratios_per_run: Vec<Vec<f64>> = vec![Vec::new(); n_runs];
411    for feat in 0..n_features {
412        let base = feat * n_runs;
413        let row = &intensities[base..base + n_runs];
414        if row.iter().any(|&v| v <= 0.0) {
415            continue; // complete cases only
416        }
417        let mean_log = row.iter().map(|&v| v.log2()).sum::<f64>() / n_runs as f64;
418        for (run, &v) in row.iter().enumerate() {
419            ratios_per_run[run].push(v.log2() - mean_log);
420        }
421    }
422    let mut size_factors: Vec<f64> = ratios_per_run
423        .iter_mut()
424        .map(|v| crate::stats::median(v))
425        .collect();
426    // Centre so the median run is unscaled.
427    let mut sf_copy = size_factors.clone();
428    let center = crate::stats::median(&mut sf_copy);
429    for s in &mut size_factors {
430        *s -= center;
431    }
432    // Apply to both target and decoy columns: intensity /= 2^size_factor[run].
433    for arr in [intensities, decoy_intensities] {
434        for feat in 0..n_features {
435            let base = feat * n_runs;
436            for run in 0..n_runs {
437                let v = arr[base + run];
438                if v > 0.0 {
439                    arr[base + run] = v / 2f64.powf(size_factors[run]);
440                }
441            }
442        }
443    }
444    size_factors
445}
446
447/// Per-feature, per-run quantification result.
448///
449/// Holds enough information to write the long-format `lfq_details` file:
450/// integration outputs (intensity / scores), RT diagnostics, and the
451/// observed monoisotopic m/z + ion mobility of the winning M hill.
452///
453/// Two isotope-quality signals, kept strictly distinct (this is the pair that
454/// was historically conflated):
455///
456/// - `spectral_bhattacharyya` = observed vs theoretical isotope abundance
457///   pattern (penalises missing peaks).
458/// - `coelution` = cosine between the matched isotopes' XIC traces across RT
459///   ("do they co-elute?").
460///
461/// hybrid_score = (rt_score × int_score × spectral_bhattacharyya × coelution)^¼
462/// at the apex column. Neither is `Feature.cosine_score` (the feature-finder's
463/// chromatographic cosine between isotope hills).
464#[derive(Debug, Clone)]
465pub struct LfqEntry {
466    /// Exclusive raw-sample accounting, separately for targets and controls.
467    pub owned_samples: usize,
468    /// Co-eluting preceding-isotope signal unexplained by this mono hypothesis.
469    pub preceding_signal_fraction: f32,
470    pub excluded_samples: usize,
471    pub competing_feature: Option<usize>,
472    pub ownership_status: &'static str,
473    pub feature_idx: usize,
474    pub run_idx: usize,
475    pub intensity: f64,
476    pub hybrid_score: f32,
477    /// Spectral **Bhattacharyya** at the apex column — observed-vs-theoretical
478    /// isotope pattern match; penalises expected-but-missing peaks. [0, 1].
479    pub spectral_bhattacharyya: f32,
480    pub n_isotopes_found: u8,
481    /// Individual hybrid components at the apex column, exposed so a downstream
482    /// rescorer can weight each independently instead of using only the
483    /// composite `hybrid_score`. `coelution` is the inter-isotope cosine
484    /// (do the matched isotopes co-elute). All bounded [0, 1].
485    pub rt_score: f32,
486    pub int_score: f32,
487    pub coelution: f32,
488    pub is_decoy: bool,
489    /// True when this run did NOT contribute a feature to the consensus group,
490    /// so the intensity was re-integrated from raw hills at the predicted RT/mz
491    /// (match-between-runs). False = an original detection contributed to the
492    /// group; both kinds are quantified by the same exclusive grid extraction.
493    /// In search-guided mode this instead means no accepted same-run MS2 ID.
494    pub is_mbr: bool,
495    /// Expected RT in native run space used to centre the XIC grid. For
496    /// targets this is the alignment-predicted RT; for decoys it is the
497    /// shifted decoy RT (`expected_rt = target_rt − decoy_rt_shift_pct · rt_span`).
498    pub expected_rt: f64,
499    /// Observed peak apex RT derived from the grid's apex bin.
500    /// NaN when no peak was found.
501    pub apex_rt: f64,
502    /// Width of the integration window in RT units (end_rt - start_rt).
503    /// 0.0 when no peak was found.
504    pub peak_width_rt: f64,
505    /// Expected monoisotopic m/z used to centre the XIC grid. For targets
506    /// this is the alignment-predicted m/z; for decoys it is the shifted
507    /// decoy m/z (`expected_mz = target_mz + decoy_mz_shift_da / charge`).
508    /// Used as the reference for the per-row ppm residual.
509    pub expected_mz: f64,
510    /// Monoisotopic (M) m/z of the winning hill. NaN when the M row was empty.
511    pub observed_mz: f64,
512    /// Expected ion mobility used to centre the XIC grid. Decoys reuse the
513    /// target IM today (no IM shift), but the field is stored explicitly so
514    /// downstream residuals always have a per-row reference.
515    pub expected_im: f64,
516    /// Ion mobility of the winning M hill. NaN when the M row was empty
517    /// or the run has no IM dimension.
518    pub observed_im: f64,
519}
520
521/// Final intensity matrix: features (rows) × runs (columns).
522pub struct IntensityMatrix {
523    /// Peptide identities and donor evidence, present only in search-guided mode.
524    pub search_guidance: Option<targets::SearchGuidance>,
525    /// Original consensus membership retained for long-format evidence export.
526    pub consensus: Vec<consensus::ConsensusFeature>,
527    pub n_features: usize,
528    pub n_runs: usize,
529    pub run_names: Vec<String>,
530    pub reference_run: String,
531    // Per-consensus-feature metadata
532    pub feature_mz: Vec<f64>,
533    pub feature_mass: Vec<f64>,
534    pub feature_charge: Vec<u8>,
535    pub feature_rt: Vec<f64>,
536    pub feature_im: Vec<f64>,
537    /// Seed feature's `combined_score` (isotope × chromato cosine) per consensus row.
538    pub feature_combined_score: Vec<f64>,
539    /// Name of the run that provided the seed feature for each consensus row.
540    pub feature_seed_run: Vec<String>,
541    /// Number of runs that contributed at least one detection to each consensus group.
542    pub feature_n_contributing_runs: Vec<usize>,
543    /// Hill count per run (recorded as each run's hills were streamed in).
544    pub hills_per_run: Vec<usize>,
545    // Flat [feature * n_runs + run] layout
546    pub intensities: Vec<f64>,
547    pub q_values: Vec<f64>,
548    /// Decoy intensities, same flat layout as `intensities`.
549    /// All zeros when `run_tdc = false`.
550    pub decoy_intensities: Vec<f64>,
551    /// Full per-(feature, run) target + decoy entries with detail stats.
552    /// Empty when `run_tdc = false` and details are not requested.
553    pub entries: Vec<LfqEntry>,
554}
555
556impl IntensityMatrix {
557    pub fn intensity(&self, feat: usize, run: usize) -> f64 {
558        self.intensities[feat * self.n_runs + run]
559    }
560
561    pub fn q_value(&self, feat: usize, run: usize) -> f64 {
562        self.q_values[feat * self.n_runs + run]
563    }
564
565    pub fn decoy_intensity(&self, feat: usize, run: usize) -> f64 {
566        self.decoy_intensities[feat * self.n_runs + run]
567    }
568}
569
570/// Run LFQ quantification across all runs.
571///
572/// Consensus features are built from all runs via `build_consensus`, which
573/// projects every feature into reference-run coordinate space and groups by
574/// (charge, neutral mass ± ppm, aligned RT ± window, IM ± tolerance).
575/// The highest-scoring feature in each group seeds the LFQ extraction.
576/// For every consensus feature × run combination the function:
577///
578/// 1. Applies the alignment corrections to the seed's reference-space coordinates.
579/// 2. Builds a 100-bin × n_isotopes XIC grid from the run's hills.
580/// 3. Scores each column and integrates the best peak.
581/// 4. Optionally repeats with a decoy feature for target-decoy q-values.
582///
583/// `runs` carry only features (their `hills` may be empty); the full hills for
584/// each run are loaded on demand.
585///
586/// CPU-nanosecond accumulators for the three hot-loop phases, summed across all
587/// rayon tasks. Kept as a small struct so `quantify_cell` can be handed one
588/// reference instead of three loose atomics.
589struct PhaseTimers {
590    build: AtomicU64,
591    score: AtomicU64,
592    integrate: AtomicU64,
593}
594
595impl PhaseTimers {
596    fn new() -> Self {
597        Self {
598            build: AtomicU64::new(0),
599            score: AtomicU64::new(0),
600            integrate: AtomicU64::new(0),
601        }
602    }
603
604    fn add_build(&self, d: std::time::Duration) {
605        self.build.fetch_add(d.as_nanos() as u64, Ordering::Relaxed);
606    }
607    fn add_score(&self, d: std::time::Duration) {
608        self.score.fetch_add(d.as_nanos() as u64, Ordering::Relaxed);
609    }
610    fn add_integrate(&self, d: std::time::Duration) {
611        self.integrate
612            .fetch_add(d.as_nanos() as u64, Ordering::Relaxed);
613    }
614}
615
616/// Quantify a single (feature, run) cell — target OR decoy — by the shared
617/// build_grid → score_grid → integrate → peak_rt_stats → `LfqEntry` sequence.
618/// Grid intensity (apex or summed, per `quant_estimator`) is always used so
619/// every reported quantity has original-sample provenance.
620#[allow(clippy::too_many_arguments)]
621fn quantify_cell(
622    grid: &mut XicGrid,
623    scores: &mut ColumnScores,
624    col_totals: &mut [f32],
625    obs: &mut [f64],
626    hills_vec: &[Hill],
627    sorted: &SortedHills,
628    scan_times: &[f64],
629    theoretical_pattern: &[f64],
630    bc_template: &[f64; K_PATTERN],
631    config: &LfqConfig,
632    timers: &PhaseTimers,
633    feat_idx: usize,
634    run_idx: usize,
635    charge: u8,
636    mz: f64,
637    rt: f64,
638    im: f64,
639    half_window: f64,
640    rt_sigma: Option<f32>,
641    is_decoy: bool,
642    is_mbr: bool,
643    excluded: &HashSet<SampleId>,
644) -> CellCandidate {
645    let t_b = Instant::now();
646    build_grid_excluding(
647        grid,
648        hills_vec,
649        sorted,
650        scan_times,
651        mz,
652        charge,
653        rt,
654        im,
655        half_window,
656        config,
657        excluded,
658    );
659    timers.add_build(t_b.elapsed());
660    let slots = grid.n_slots_filled;
661    let obs_mz = grid.winner_mz[0];
662    let obs_im = grid.winner_im[0];
663
664    let t_s = Instant::now();
665    score_grid(
666        grid,
667        theoretical_pattern,
668        bc_template,
669        config,
670        scores,
671        col_totals,
672        obs,
673        rt_sigma,
674    );
675    timers.add_score(t_s.elapsed());
676
677    let t_i = Instant::now();
678    let peak = integrate(grid, scores, col_totals, config);
679    timers.add_integrate(t_i.elapsed());
680    let (apex_rt, peak_width) = peak_rt_stats(grid, config.grid_cols, &peak);
681
682    let use_apex = config.quant_estimator.eq_ignore_ascii_case("apex");
683    let intensity = if use_apex {
684        peak.apex_intensity
685    } else {
686        peak.intensity
687    };
688
689    let mut support: Vec<_> = grid
690        .samples
691        .iter()
692        .filter(|s| peak.intensity > 0.0 && s.col >= peak.start_bin && s.col <= peak.end_bin)
693        .map(|s| s.id)
694        .collect();
695    support.sort_unstable();
696    support.dedup();
697    let mut rows = vec![false; config.n_isotopes];
698    for s in &grid.samples {
699        if peak.intensity > 0.0 && s.col >= peak.start_bin && s.col <= peak.end_bin {
700            rows[s.row] = true;
701        }
702    }
703    // Compare mono hypotheses against the preceding isotope position as well.
704    // An M+1 alias may fit its truncated right-hand envelope while leaving a
705    // substantial co-eluting M peak unexplained. This is ranking evidence only;
706    // the preceding row is never included in this candidate's reported quantity.
707    let preceding_signal_fraction = if peak.intensity > 0.0 {
708        let mut preceding = XicGrid::empty(1, config.grid_cols, 0.0, 1.0);
709        let mut one_row = config.clone();
710        one_row.n_isotopes = 1;
711        build_grid_excluding(
712            &mut preceding,
713            hills_vec,
714            sorted,
715            scan_times,
716            mz - grid::C13_NEUTRON / charge as f64,
717            charge,
718            rt,
719            im,
720            half_window,
721            &one_row,
722            excluded,
723        );
724        let (mut dot, mut norm_a, mut norm_b) = (0.0f64, 0.0f64, 0.0f64);
725        for (&a, &b) in preceding.intensities[0].iter().zip(&grid.intensities[0]) {
726            let (a, b) = (f64::from(a), f64::from(b));
727            dot += a * b;
728            norm_a += a * a;
729            norm_b += b * b;
730        }
731        let coelution = if norm_a > 0.0 && norm_b > 0.0 {
732            (dot / (norm_a * norm_b).sqrt()).clamp(0.0, 1.0)
733        } else {
734            0.0
735        };
736        let previous: f64 = preceding.intensities[0][peak.start_bin..=peak.end_bin]
737            .iter()
738            .map(|&v| f64::from(v))
739            .sum();
740        let positive: f64 = col_totals[peak.start_bin..=peak.end_bin]
741            .iter()
742            .map(|&v| f64::from(v))
743            .sum();
744        (coelution * previous / (previous + positive).max(f64::MIN_POSITIVE)) as f32
745    } else {
746        0.0
747    };
748    let bin_width = (grid.rt_max - grid.rt_min) / config.grid_cols as f64;
749    CellCandidate {
750        peak_start: grid.rt_min + peak.start_bin as f64 * bin_width,
751        peak_end: grid.rt_min + (peak.end_bin + 1) as f64 * bin_width,
752        peak_rows: rows.iter().filter(|&&v| v).count(),
753        entry: LfqEntry {
754            owned_samples: support.len(),
755            preceding_signal_fraction,
756            excluded_samples: 0,
757            competing_feature: None,
758            ownership_status: "exclusive",
759
760            feature_idx: feat_idx,
761            run_idx,
762            intensity,
763            hybrid_score: peak.hybrid_score,
764            spectral_bhattacharyya: peak.bhattacharyya_at_apex,
765            n_isotopes_found: slots,
766            rt_score: peak.rt_score_at_apex,
767            int_score: peak.int_score_at_apex,
768            coelution: peak.coelution,
769            is_decoy,
770            is_mbr,
771            expected_rt: rt,
772            apex_rt,
773            peak_width_rt: peak_width,
774            expected_mz: mz,
775            observed_mz: obs_mz,
776            expected_im: im,
777            observed_im: obs_im,
778        },
779        context: {
780            let mut ids: Vec<_> = grid.samples.iter().map(|s| s.id).collect();
781            ids.sort_unstable();
782            ids.dedup();
783            ids
784        },
785        support,
786    }
787}
788
789/// Log the per-run detection summary (target/decoy counts, or plain detected
790/// counts when TDC is off). Pure logging — no effect on the output matrix.
791fn log_detection_summary(
792    all_entries: &[LfqEntry],
793    runs: &[RunInput],
794    n_features: usize,
795    n_runs: usize,
796    run_tdc: bool,
797) {
798    for run_idx in 0..n_runs {
799        let n_detected = all_entries
800            .iter()
801            .filter(|e| !e.is_decoy && e.run_idx == run_idx && e.intensity > 0.0)
802            .count();
803        let n_decoy = all_entries
804            .iter()
805            .filter(|e| e.is_decoy && e.run_idx == run_idx && e.intensity > 0.0)
806            .count();
807        if run_tdc {
808            log::info!(
809                "LFQ: run '{}': {}/{} target detected ({:.1}%), {} decoy",
810                runs[run_idx].name,
811                n_detected,
812                n_features,
813                100.0 * n_detected as f64 / n_features as f64,
814                n_decoy,
815            );
816        } else {
817            log::info!(
818                "LFQ: run '{}': {}/{} features detected ({:.1}%)",
819                runs[run_idx].name,
820                n_detected,
821                n_features,
822                100.0 * n_detected as f64 / n_features as f64,
823            );
824        }
825    }
826}
827
828/// Fold the per-(feature, run) target/decoy `entries` into the flat intensity /
829/// q-value / decoy-intensity matrices, apply optional median-of-ratios
830/// normalization, and build the final `IntensityMatrix`. Takes `all_entries` by
831/// value — it is stored in the returned matrix.
832#[allow(clippy::too_many_arguments)]
833fn assemble_matrix(
834    consensus: &[ConsensusFeature],
835    runs: &[RunInput],
836    alignment: &AlignmentResult,
837    config: &LfqConfig,
838    all_entries: Vec<LfqEntry>,
839    q_map: &HashMap<(usize, usize), f64>,
840    hills_per_run: Vec<usize>,
841    n_features: usize,
842    n_runs: usize,
843) -> IntensityMatrix {
844    let t_assemble = Instant::now();
845    let mut intensities = vec![0.0f64; n_features * n_runs];
846    let mut q_values_out = vec![1.0f64; n_features * n_runs];
847    let mut decoy_intensities = vec![0.0f64; n_features * n_runs];
848
849    for entry in &all_entries {
850        let flat = entry.feature_idx * n_runs + entry.run_idx;
851        if entry.is_decoy {
852            decoy_intensities[flat] = entry.intensity;
853        } else {
854            intensities[flat] = entry.intensity;
855            if let Some(&qv) = q_map.get(&(entry.feature_idx, entry.run_idx)) {
856                q_values_out[flat] = qv;
857            }
858        }
859    }
860
861    log::info!(
862        "[timing] intensity matrix assemble: {:.2?}",
863        t_assemble.elapsed()
864    );
865
866    if config.normalize.eq_ignore_ascii_case("median_ratios") {
867        let sf = apply_median_ratio_normalization(
868            &mut intensities,
869            &mut decoy_intensities,
870            n_features,
871            n_runs,
872        );
873        let lo = sf.iter().cloned().fold(f64::INFINITY, f64::min);
874        let hi = sf.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
875        log::info!(
876            "LFQ normalization: median-of-ratios size factors applied, \
877             per-run log2 range [{:.3}, {:.3}] (~{:.1}% max scale)",
878            lo,
879            hi,
880            (2f64.powf(hi - lo) - 1.0) * 100.0
881        );
882    }
883
884    IntensityMatrix {
885        search_guidance: None,
886        consensus: consensus.to_vec(),
887        n_features,
888        n_runs,
889        run_names: runs.iter().map(|r| r.name.clone()).collect(),
890        reference_run: alignment.reference_name.clone(),
891        feature_mz: consensus.iter().map(|f| f.ref_mz).collect(),
892        feature_mass: consensus.iter().map(|f| f.neutral_mass).collect(),
893        feature_charge: consensus.iter().map(|f| f.charge).collect(),
894        feature_rt: consensus.iter().map(|f| f.ref_rt).collect(),
895        feature_im: consensus.iter().map(|f| f.ref_im).collect(),
896        feature_combined_score: consensus.iter().map(|f| f.seed_combined_score).collect(),
897        feature_seed_run: consensus
898            .iter()
899            .map(|f| runs[f.seed_run_idx].name.clone())
900            .collect(),
901        feature_n_contributing_runs: consensus.iter().map(|f| f.n_contributing_runs).collect(),
902        hills_per_run,
903        intensities,
904        q_values: q_values_out,
905        decoy_intensities,
906        entries: all_entries,
907    }
908}
909
910pub fn quantify(
911    runs: &[RunInput],
912    alignment: &AlignmentResult,
913    config: &LfqConfig,
914    load_hills: impl Fn(usize) -> Vec<crate::models::Hill>,
915) -> IntensityMatrix {
916    let t_consensus = Instant::now();
917    let consensus = build_consensus(runs, alignment, config);
918    log::info!("[timing] build_consensus: {:.2?}", t_consensus.elapsed());
919    quantify_consensus(runs, alignment, config, consensus, load_hills)
920}
921
922/// Quantify an already selected candidate set using the shared exclusive
923/// extraction and cell-rescoring pipeline. Used by controlled grouping comparisons.
924pub fn quantify_consensus(
925    runs: &[RunInput],
926    alignment: &AlignmentResult,
927    config: &LfqConfig,
928    consensus: Vec<ConsensusFeature>,
929    load_hills: impl Fn(usize) -> Vec<crate::models::Hill>,
930) -> IntensityMatrix {
931    quantify_candidates(runs, alignment, config, consensus, None, load_hills)
932}
933
934/// Quantify imported peptide targets with the same signal ownership and estimator
935/// as identification-free LFQ. The guidance and consensus rows must correspond.
936pub fn quantify_guided(
937    runs: &[RunInput],
938    alignment: &AlignmentResult,
939    config: &LfqConfig,
940    consensus: Vec<ConsensusFeature>,
941    guidance: targets::SearchGuidance,
942    load_hills: impl Fn(usize) -> Vec<crate::models::Hill>,
943) -> IntensityMatrix {
944    assert_eq!(consensus.len(), guidance.targets.len());
945    let mut matrix = quantify_candidates(
946        runs,
947        alignment,
948        config,
949        consensus,
950        Some(&guidance),
951        load_hills,
952    );
953    matrix.search_guidance = Some(guidance);
954    matrix
955}
956
957fn quantify_candidates(
958    runs: &[RunInput],
959    alignment: &AlignmentResult,
960    config: &LfqConfig,
961    consensus: Vec<ConsensusFeature>,
962    guidance: Option<&targets::SearchGuidance>,
963    load_hills: impl Fn(usize) -> Vec<crate::models::Hill>,
964) -> IntensityMatrix {
965    // Guard against a degenerate grid: grid_cols / n_isotopes of 0 would make
966    // `n_cols - 1` underflow and index into empty rows (panic). Clamp to 1 and
967    // warn rather than crash on a misconfigured TOML.
968    let config = &if config.grid_cols == 0 || config.n_isotopes == 0 {
969        log::warn!(
970            "LFQ config has grid_cols={} n_isotopes={}; clamping each to a minimum of 1",
971            config.grid_cols,
972            config.n_isotopes
973        );
974        let mut c = config.clone();
975        c.grid_cols = c.grid_cols.max(1);
976        c.n_isotopes = c.n_isotopes.max(1);
977        c
978    } else {
979        config.clone()
980    };
981
982    let n_features = consensus.len();
983    let n_runs = runs.len();
984
985    log::info!("LFQ: {} consensus features × {} runs", n_features, n_runs);
986
987    // Full-length averagine Bhattacharyya templates, one per consensus feature.
988    // Each depends only on `cf.neutral_mass`, so precompute here instead of
989    // rebuilding inside every (run × feature × target/decoy) score_grid call.
990    let isotope_model = config.isotope_model.model();
991    let bc_templates: Vec<_> = consensus
992        .iter()
993        .map(|cf| isotope_model.distribution(cf.neutral_mass))
994        .collect();
995
996    // (Audit A2) Decoy-specific averagine template + theoretical pattern, computed
997    // from the DECOY's shifted neutral mass (target mass + `decoy_mz_shift_da`,
998    // the same +Da the decoy grid is placed at). Only consulted when
999    // `config.decoy_own_template` is set; otherwise the decoy reuses the target's
1000    // template below (byte-identical to the shipped paper q-values). Precomputed
1001    // once per feature, like `bc_templates`, so the hot loop stays allocation-free.
1002    let decoy_bc_templates: Vec<[f64; K_PATTERN]> = if config.decoy_own_template {
1003        consensus
1004            .iter()
1005            .map(|cf| isotope_model.distribution(cf.neutral_mass + config.decoy_mz_shift_da))
1006            .collect()
1007    } else {
1008        Vec::new()
1009    };
1010    let decoy_theoretical_patterns: Vec<Vec<f64>> = if config.decoy_own_template {
1011        consensus
1012            .iter()
1013            .map(|cf| {
1014                // Normalised averagine pattern at the decoy mass, truncated to the
1015                // same length as the target's `theoretical_pattern` so the grid's
1016                // `n_rows` trimming/weighting behaves identically to the target.
1017                let k = cf.theoretical_pattern.len().min(K_PATTERN);
1018                let full = isotope_model.distribution(cf.neutral_mass + config.decoy_mz_shift_da);
1019                let sum: f64 = full[..k].iter().sum();
1020                if sum > 0.0 {
1021                    full[..k].iter().map(|&x| x / sum).collect()
1022                } else {
1023                    vec![0.0; k]
1024                }
1025            })
1026            .collect()
1027    } else {
1028        Vec::new()
1029    };
1030
1031    // Precompute each run's RT range once (from features — no hills needed).
1032    // `RunInput::rt_range` falls back to a full sweep of `run.features` when
1033    // `scan_times` is empty (it always is for the align pipeline), so calling it
1034    // inside the hot loop would cost ~10ms × n_features × n_runs.
1035    let t_rt = Instant::now();
1036    let run_rt_ranges: Vec<(f64, f64)> = runs.par_iter().map(|r| r.rt_range()).collect();
1037    log::info!("[timing] run_rt_ranges precompute: {:.2?}", t_rt.elapsed());
1038
1039    // Quantify RUN-MAJOR: load one run's hills, quantify every consensus feature
1040    // against it, then drop the hills before loading the next run. Peak memory is
1041    // O(one run's hills) instead of O(all runs' hills loaded at once).
1042    let t_quant = Instant::now();
1043    // Per-phase nanosecond accumulators across all rayon tasks.
1044    let timers = PhaseTimers::new();
1045    let mut all_entries: Vec<LfqEntry> = Vec::new();
1046    let mut hills_per_run: Vec<usize> = vec![0; n_runs];
1047
1048    if !config.detected_use_grid {
1049        log::warn!("Exclusive LFQ extraction requires grid intensities; detected_use_grid=false is ignored");
1050    }
1051    let mut priority = [Vec::new(), Vec::new()];
1052    // First inspect the immutable signal in every run. Then use one cross-run
1053    // preference per hypothesis, avoiding run-specific switching of aliases.
1054    // Hills remain streamed; only scalar candidate evidence survives pass one.
1055    for pass in 0..2 {
1056        if pass == 1 {
1057            priority = ownership::global_priority(&all_entries, &consensus);
1058            all_entries.clear();
1059        }
1060        for run_idx in 0..n_runs {
1061            let run = &runs[run_idx];
1062            let run_rt_range = run_rt_ranges[run_idx];
1063            let t_load = Instant::now();
1064            let hills_vec = load_hills(run_idx);
1065            hills_per_run[run_idx] = hills_vec.len();
1066            // Size the grid so each column averages a similar number of scans
1067            // whatever the instrument's acquisition rate.
1068            let run_config = if config.grid_scans_per_column > 0.0 {
1069                let window = 2.0 * config.rt_window_pct * (run_rt_range.1 - run_rt_range.0);
1070                match median_scan_spacing(&hills_vec) {
1071                    Some(spacing) if window > 0.0 => {
1072                        let wanted = window / (config.grid_scans_per_column * spacing);
1073                        let cols = (wanted.round() as usize).clamp(6, config.grid_cols);
1074                        if pass == 0 {
1075                            log::info!(
1076                                "[lfq] '{}': {:.2} s scan spacing over a {:.3} min window -> {} grid columns",
1077                                run.name,
1078                                spacing * 60.0,
1079                                window,
1080                                cols
1081                            );
1082                        }
1083                        LfqConfig {
1084                            grid_cols: cols,
1085                            ..config.clone()
1086                        }
1087                    }
1088                    _ => config.clone(),
1089                }
1090            } else {
1091                config.clone()
1092            };
1093            let config = &run_config;
1094            let sorted = SortedHills::from_hills(&hills_vec);
1095            log::info!(
1096                "[lfq] run {}/{} '{}': {} hills loaded [{:.2?}]",
1097                run_idx + 1,
1098                n_runs,
1099                run.name,
1100                hills_vec.len(),
1101                t_load.elapsed()
1102            );
1103
1104            let extract = |feat_idx: usize, is_decoy: bool, excluded: &HashSet<SampleId>| {
1105                let cf = &consensus[feat_idx];
1106                let mut grid = XicGrid::empty(config.n_isotopes, config.grid_cols, 0.0, 1.0);
1107                let mut scores = ColumnScores::new(config.grid_cols);
1108                let mut col_totals = vec![0.0f32; config.grid_cols];
1109                let mut obs = vec![0.0f64; config.n_isotopes];
1110                let (mut corr_rt, corr_mz, mut corr_im) = if run_idx == alignment.reference_idx {
1111                    (cf.ref_rt, cf.ref_mz, cf.ref_im)
1112                } else {
1113                    let al = &alignment.alignments[&run.name];
1114                    (
1115                        al.predict_run_rt(cf.ref_rt),
1116                        al.predict_run_mz(cf.ref_mz, cf.ref_rt),
1117                        al.predict_run_im(cf.ref_im, cf.ref_rt),
1118                    )
1119                };
1120                if let Some(g) = guidance {
1121                    // A same-run MS2 observation supplies native RT/IM. Its paired
1122                    // decoy uses the same centre before the usual coordinate shifts.
1123                    if let Some(id) = g.native_anchor(feat_idx, run_idx) {
1124                        corr_rt = id.rt_minutes;
1125                        corr_im = id.im;
1126                    } else if cf.ref_im == 0.0 {
1127                        corr_im = 0.0;
1128                    }
1129                }
1130                let span = run_rt_range.1 - run_rt_range.0;
1131                let rt = corr_rt
1132                    - if is_decoy {
1133                        config.decoy_rt_shift_pct * span
1134                    } else {
1135                        0.0
1136                    };
1137                let mz = corr_mz
1138                    + if is_decoy {
1139                        config.decoy_mz_shift_da / cf.charge as f64
1140                    } else {
1141                        0.0
1142                    };
1143                let rt_sigma = if config.rt_spread_scoring && run_idx != alignment.reference_idx {
1144                    let al = &alignment.alignments[&run.name];
1145                    let norm = if span > 0.0 {
1146                        ((rt - run_rt_range.0) / span).clamp(0.0, 1.0)
1147                    } else {
1148                        0.5
1149                    };
1150                    al.rt_sigma.sigma_norm_at(norm).map(|s| {
1151                        (s * config.grid_cols as f64 / (2.0 * config.rt_window_pct)) as f32
1152                    })
1153                } else {
1154                    None
1155                };
1156                let (pattern, template) = if is_decoy && config.decoy_own_template {
1157                    (
1158                        decoy_theoretical_patterns[feat_idx].as_slice(),
1159                        &decoy_bc_templates[feat_idx],
1160                    )
1161                } else {
1162                    (cf.theoretical_pattern.as_slice(), &bc_templates[feat_idx])
1163                };
1164                let mut candidate = quantify_cell(
1165                    &mut grid,
1166                    &mut scores,
1167                    &mut col_totals,
1168                    &mut obs,
1169                    &hills_vec,
1170                    &sorted,
1171                    &run.scan_times,
1172                    pattern,
1173                    template,
1174                    config,
1175                    &timers,
1176                    feat_idx,
1177                    run_idx,
1178                    cf.charge,
1179                    mz,
1180                    rt,
1181                    corr_im,
1182                    config.rt_window_pct * span,
1183                    rt_sigma,
1184                    is_decoy,
1185                    !is_decoy
1186                        && guidance.map_or(cf.per_run_feature[run_idx].is_none(), |g| {
1187                            !g.is_direct(feat_idx, run_idx)
1188                        }),
1189                    excluded,
1190                );
1191                // Apply the same minimum envelope support to inferred targets
1192                // and their paired decoys before ownership and confidence scoring.
1193                if guidance.is_some_and(|g| g.is_inferred(feat_idx, run_idx))
1194                    && (candidate.entry.n_isotopes_found < 2 || candidate.entry.coelution < 0.5)
1195                {
1196                    candidate.entry.intensity = 0.0;
1197                    candidate.support.clear();
1198                    candidate.context.clear();
1199                }
1200                candidate
1201            };
1202            let empty = HashSet::new();
1203            let candidates: Vec<CellCandidate> = (0..n_features)
1204                .into_par_iter()
1205                .filter(|&i| guidance.is_none_or(|g| g.attempted(i, run_idx)))
1206                .flat_map_iter(|i| {
1207                    let mut cells = vec![extract(i, false, &empty)];
1208                    if config.run_tdc {
1209                        cells.push(extract(i, true, &empty));
1210                    }
1211                    cells
1212                })
1213                .collect();
1214            if pass == 0 {
1215                all_entries.extend(candidates.into_iter().map(|c| c.entry));
1216            } else {
1217                let resolved = ownership::resolve_run(candidates, &priority, &hills_vec, extract);
1218                let conflicts = resolved.iter().filter(|e| e.excluded_samples > 0).count();
1219                log::info!(
1220                    "[lfq ownership] '{}': {} cells reassessed for shared signal",
1221                    run.name,
1222                    conflicts
1223                );
1224                all_entries.extend(resolved);
1225            }
1226            // `hills_vec` and `sorted` are dropped here before the next run loads.
1227        }
1228    }
1229    log::info!(
1230        "[timing] quantification loop ({} entries, {} runs streamed): {:.2?}",
1231        all_entries.len(),
1232        n_runs,
1233        t_quant.elapsed()
1234    );
1235    let ns_b = timers.build.load(Ordering::Relaxed);
1236    let ns_s = timers.score.load(Ordering::Relaxed);
1237    let ns_i = timers.integrate.load(Ordering::Relaxed);
1238    let total_ns = ns_b + ns_s + ns_i;
1239    let pct = |ns: u64| {
1240        if total_ns == 0 {
1241            0.0
1242        } else {
1243            100.0 * ns as f64 / total_ns as f64
1244        }
1245    };
1246    log::info!(
1247        "[timing] hot-loop breakdown (cpu-ns across all threads): \
1248         build_grid={:.2}s ({:.0}%), score_grid={:.2}s ({:.0}%), integrate={:.2}s ({:.0}%)",
1249        ns_b as f64 / 1e9,
1250        pct(ns_b),
1251        ns_s as f64 / 1e9,
1252        pct(ns_s),
1253        ns_i as f64 / 1e9,
1254        pct(ns_i),
1255    );
1256
1257    // Target-decoy q-values
1258    let t_tdc = Instant::now();
1259    let q_map: HashMap<(usize, usize), f64> = if config.run_tdc {
1260        if let Some(g) = guidance {
1261            let peptides: Vec<_> = g
1262                .targets
1263                .iter()
1264                .map(|t| t.modified_peptide.clone())
1265                .collect();
1266            let charges: Vec<_> = g.targets.iter().map(|t| t.charge).collect();
1267            // Do not let abundant direct IDs dilute the transferred-cell null.
1268            // Partition paired decoys using the same donor mask as targets.
1269            let mut q = HashMap::new();
1270            // Keep same-run inferred-charge evidence separate from both direct
1271            // PSMs and cross-run transfers, for targets and paired decoys alike.
1272            let class = |e: &LfqEntry| {
1273                if g.is_direct(e.feature_idx, e.run_idx) {
1274                    1
1275                } else if g.is_inferred(e.feature_idx, e.run_idx)
1276                    && g.native_anchor(e.feature_idx, e.run_idx).is_some()
1277                {
1278                    2
1279                } else {
1280                    0
1281                }
1282            };
1283            for evidence in 0..=2 {
1284                let entries: Vec<_> = all_entries
1285                    .iter()
1286                    .filter(|e| class(e) == evidence)
1287                    .cloned()
1288                    .collect();
1289                q.extend(rescore::search::compute(
1290                    &entries,
1291                    &peptides,
1292                    &charges,
1293                    config.search_scoring,
1294                ));
1295            }
1296            q
1297        } else {
1298            rescore::compute_qvalues_qda(&all_entries)
1299        }
1300    } else {
1301        all_entries
1302            .iter()
1303            .filter(|e| !e.is_decoy)
1304            .map(|e| ((e.feature_idx, e.run_idx), 1.0))
1305            .collect()
1306    };
1307    log::info!("[timing] tdc q-values: {:.2?}", t_tdc.elapsed());
1308
1309    // Per-run detection summary
1310    let t_summary = Instant::now();
1311    log_detection_summary(&all_entries, runs, n_features, n_runs, config.run_tdc);
1312    log::info!(
1313        "[timing] per-run detection summary: {:.2?}",
1314        t_summary.elapsed()
1315    );
1316
1317    // Assemble output matrix
1318    assemble_matrix(
1319        &consensus,
1320        runs,
1321        alignment,
1322        config,
1323        all_entries,
1324        &q_map,
1325        hills_per_run,
1326        n_features,
1327        n_runs,
1328    )
1329}