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}